feat: copy bech32

This commit is contained in:
Alejandro Gómez
2025-12-11 21:54:02 +01:00
parent 701a98bd49
commit b7fbaf5e46
6 changed files with 148 additions and 133 deletions

18
TODO.md
View File

@@ -24,21 +24,6 @@ Current RTL implementation is partial and has limitations:
**Test case**: Arabic text with hashtags on same line should display properly with right-alignment.
### NIP-05 Resolution with @ Prefix
**Priority**: High
**File**: `src/lib/nip05.ts`
**Issue**: Commands like `req -a @fiatjaf.com` (without username, just @domain) return unexpected results.
**Current behavior**:
- `req -a fiatjaf.com` works (normalized to `_@fiatjaf.com`) ✅
- `req -a user@fiatjaf.com` works ✅
- `req -a @fiatjaf.com` fails - not recognized as valid NIP-05 ❌
**Root cause**: The `isNip05()` regex patterns don't match the `@domain.com` format (@ prefix without username).
**Solution**: Either normalize `@domain.com``_@domain.com` or show helpful error message.
### Live Mode Reliability
**Priority**: High
**File**: `src/components/ReqViewer.tsx`
@@ -66,9 +51,6 @@ When selecting an action from the dropdown, pressing Enter should insert the com
### Command Options Display
When an action is entered, show the list of available options below and provide auto-completion for flags/arguments.
### Date Display
Show timestamps/dates for notes in feed views for better chronological context.
## Feature Requests
### Command History

View File

@@ -2,6 +2,12 @@ import { useEffect, useState } from "react";
import { Command } from "cmdk";
import { useGrimoire } from "@/core/state";
import { manPages } from "@/types/man";
import {
Dialog,
DialogContent,
DialogTitle,
} from "@/components/ui/dialog";
import { VisuallyHidden } from "@/components/ui/visually-hidden";
import "./command-launcher.css";
interface CommandLauncherProps {
@@ -90,89 +96,96 @@ export default function CommandLauncher({
: "Type a command...";
return (
<Command.Dialog
open={open}
onOpenChange={onOpenChange}
label="Command Launcher"
className="grimoire-command-launcher"
shouldFilter={false}
>
<div className="command-launcher-wrapper">
<Command.Input
value={input}
onValueChange={setInput}
onKeyDown={handleKeyDown}
placeholder={placeholder}
className="command-input"
/>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="grimoire-command-launcher p-0">
<VisuallyHidden>
<DialogTitle>Command Launcher</DialogTitle>
</VisuallyHidden>
<Command
label="Command Launcher"
className="grimoire-command-content"
shouldFilter={false}
>
<div className="command-launcher-wrapper">
<Command.Input
value={input}
onValueChange={setInput}
onKeyDown={handleKeyDown}
placeholder={placeholder}
className="command-input"
/>
{recognizedCommand && args.length > 0 && (
<div className="command-hint">
<span className="command-hint-label">Parsed:</span>
<span className="command-hint-command">{commandName}</span>
<span className="command-hint-args">{args.join(" ")}</span>
{recognizedCommand && args.length > 0 && (
<div className="command-hint">
<span className="command-hint-label">Parsed:</span>
<span className="command-hint-command">{commandName}</span>
<span className="command-hint-args">{args.join(" ")}</span>
</div>
)}
<Command.List className="command-list">
<Command.Empty className="command-empty">
{commandName
? `No command found: ${commandName}`
: "Start typing..."}
</Command.Empty>
{categories.map((category) => (
<Command.Group
key={category}
heading={category}
className="command-group"
>
{filteredCommands
.filter(([_, cmd]) => cmd.category === category)
.map(([name, cmd]) => {
const isExactMatch = name === commandName;
return (
<Command.Item
key={name}
value={name}
onSelect={() => handleSelect(name)}
className="command-item"
data-exact-match={isExactMatch}
>
<div className="command-item-content">
<div className="command-item-name">
<span className="command-name">{name}</span>
{cmd.synopsis !== name && (
<span className="command-args">
{cmd.synopsis.replace(name, "").trim()}
</span>
)}
{isExactMatch && (
<span className="command-match-indicator">
</span>
)}
</div>
<div className="command-item-description">
{cmd.description.split(".")[0]}
</div>
</div>
</Command.Item>
);
})}
</Command.Group>
))}
</Command.List>
<div className="command-footer">
<div>
<kbd></kbd> navigate
<kbd></kbd> execute
<kbd>esc</kbd> close
</div>
{recognizedCommand && (
<div className="command-footer-status">Ready to execute</div>
)}
</div>
</div>
)}
<Command.List className="command-list">
<Command.Empty className="command-empty">
{commandName
? `No command found: ${commandName}`
: "Start typing..."}
</Command.Empty>
{categories.map((category) => (
<Command.Group
key={category}
heading={category}
className="command-group"
>
{filteredCommands
.filter(([_, cmd]) => cmd.category === category)
.map(([name, cmd]) => {
const isExactMatch = name === commandName;
return (
<Command.Item
key={name}
value={name}
onSelect={() => handleSelect(name)}
className="command-item"
data-exact-match={isExactMatch}
>
<div className="command-item-content">
<div className="command-item-name">
<span className="command-name">{name}</span>
{cmd.synopsis !== name && (
<span className="command-args">
{cmd.synopsis.replace(name, "").trim()}
</span>
)}
{isExactMatch && (
<span className="command-match-indicator"></span>
)}
</div>
<div className="command-item-description">
{cmd.description.split(".")[0]}
</div>
</div>
</Command.Item>
);
})}
</Command.Group>
))}
</Command.List>
<div className="command-footer">
<div>
<kbd></kbd> navigate
<kbd></kbd> execute
<kbd>esc</kbd> close
</div>
{recognizedCommand && (
<div className="command-footer-status">Ready to execute</div>
)}
</div>
</div>
</Command.Dialog>
</Command>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,44 +1,17 @@
/* Command Launcher Styles - Terminal Aesthetic */
.grimoire-command-launcher {
position: fixed;
inset: 0;
z-index: 50;
background: rgba(0, 0, 0, 0.8);
display: flex;
align-items: flex-start;
justify-content: center;
padding-top: 20vh;
animation: fadeIn 0.2s ease;
max-width: 640px;
padding: 0;
overflow: hidden;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
.grimoire-command-content {
width: 100%;
}
.command-launcher-wrapper {
width: 100%;
max-width: 640px;
background: hsl(var(--background));
border: 1px solid hsl(var(--border));
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
overflow: hidden;
animation: slideDown 0.2s ease;
}
@keyframes slideDown {
from {
transform: translateY(-20px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
.command-input {

View File

@@ -15,6 +15,7 @@ import { useGrimoire } from "@/core/state";
import { useCopy } from "@/hooks/useCopy";
import { JsonViewer } from "@/components/JsonViewer";
import { formatTimestamp } from "@/hooks/useLocale";
import { nip19 } from "nostr-tools";
// NIP-01 Kind ranges
const REPLACEABLE_START = 10000;
@@ -76,7 +77,29 @@ export function EventMenu({ event }: { event: NostrEvent }) {
};
const copyEventId = () => {
copy(event.id);
// For replaceable/parameterized replaceable events, encode as naddr
const isAddressable =
(event.kind >= REPLACEABLE_START && event.kind < REPLACEABLE_END) ||
(event.kind >= PARAMETERIZED_REPLACEABLE_START &&
event.kind < PARAMETERIZED_REPLACEABLE_END);
if (isAddressable) {
// Find d-tag for identifier
const dTag = event.tags.find((t) => t[0] === "d")?.[1] || "";
const naddr = nip19.naddrEncode({
kind: event.kind,
pubkey: event.pubkey,
identifier: dTag,
});
copy(naddr);
} else {
// For regular events, encode as nevent
const nevent = nip19.neventEncode({
id: event.id,
author: event.pubkey,
});
copy(nevent);
}
};
const viewEventJson = () => {

View File

@@ -0,0 +1,19 @@
import * as React from "react";
import { cn } from "@/lib/utils";
/**
* VisuallyHidden component for accessibility
* Hides content visually but keeps it available for screen readers
*/
export const VisuallyHidden = React.forwardRef<
HTMLSpanElement,
React.HTMLAttributes<HTMLSpanElement>
>(({ className, ...props }, ref) => (
<span
ref={ref}
className={cn("sr-only", className)}
{...props}
/>
));
VisuallyHidden.displayName = "VisuallyHidden";

View File

@@ -13,4 +13,9 @@ export default defineConfig({
"@": path.resolve(__dirname, "./src"),
},
},
server: {
hmr: {
overlay: true,
},
},
});