feat: add Grimoire member system with special NIP-05 usernames

Implements a member verification system for Grimoire project contributors
with custom usernames and visual badges.

Features:
- Member registry with pubkey to username mapping
- _ (underscore) username for ce3cd5ba...
- verbiricha username for 7fa56f5d...
- Special @grimoire.pro NIP-05 style display
- BookOpen icon badge for verified members
- Integration with UserName and NIP-05 components
- Comprehensive test suite for member utilities

The system prioritizes Grimoire member usernames over regular NIP-05
identifiers and adds visual badges throughout the UI for member recognition.
This commit is contained in:
Claude
2026-01-18 10:37:03 +00:00
parent 7fae344dd9
commit 64c568aa6f
6 changed files with 347 additions and 3 deletions

View File

@@ -0,0 +1,72 @@
import { getGrimoireMember } from "@/lib/grimoire-members";
import { BookOpen } from "lucide-react";
import { cn } from "@/lib/utils";
/**
* Grimoire Username Component
*
* Displays Grimoire member usernames with special styling and verification badge.
* If the pubkey belongs to a Grimoire member, shows their custom username
* with a Grimoire badge icon. Otherwise returns null.
*/
export function GrimoireUsername({
pubkey,
className,
showIcon = true,
}: {
pubkey: string;
className?: string;
showIcon?: boolean;
}) {
const member = getGrimoireMember(pubkey);
if (!member) {
return null;
}
return (
<span
className={cn(
"inline-flex items-center gap-1.5 text-accent font-medium",
className,
)}
title={`Grimoire member: ${member.nip05}`}
>
<span>{member.username}@grimoire.pro</span>
{showIcon && (
<BookOpen
className="h-3.5 w-3.5 text-accent"
aria-label="Grimoire member"
/>
)}
</span>
);
}
/**
* Grimoire Badge Component
*
* Shows just the verification badge icon for Grimoire members.
* Useful for adding next to existing username displays.
*/
export function GrimoireBadge({
pubkey,
className,
}: {
pubkey: string;
className?: string;
}) {
const member = getGrimoireMember(pubkey);
if (!member) {
return null;
}
return (
<BookOpen
className={cn("h-3.5 w-3.5 text-accent", className)}
title={`Grimoire member: ${member.nip05}`}
aria-label="Grimoire member"
/>
);
}

View File

@@ -2,6 +2,7 @@ import { useProfile } from "@/hooks/useProfile";
import { getDisplayName } from "@/lib/nostr-utils";
import { cn } from "@/lib/utils";
import { useGrimoire } from "@/core/state";
import { GrimoireBadge } from "./GrimoireUsername";
interface UserNameProps {
pubkey: string;
@@ -14,6 +15,7 @@ interface UserNameProps {
* Shows placeholder derived from pubkey while loading or if no profile exists
* Clicking opens the user's profile
* Uses highlight color for the logged-in user (themeable amber)
* Shows Grimoire badge for Grimoire members
*/
export function UserName({ pubkey, isMention, className }: UserNameProps) {
const { addWindow, state } = useGrimoire();
@@ -32,14 +34,17 @@ export function UserName({ pubkey, isMention, className }: UserNameProps) {
<span
dir="auto"
className={cn(
"font-semibold cursor-crosshair hover:underline hover:decoration-dotted",
"inline-flex items-center gap-1.5 font-semibold cursor-crosshair hover:underline hover:decoration-dotted",
isActiveAccount ? "text-highlight" : "text-accent",
className,
)}
onClick={handleClick}
>
{isMention ? "@" : null}
{displayName}
<span>
{isMention ? "@" : null}
{displayName}
</span>
<GrimoireBadge pubkey={pubkey} />
</span>
);
}

View File

@@ -1,5 +1,7 @@
import { useNip05 } from "@/hooks/useNip05";
import { ProfileContent } from "applesauce-core/helpers";
import { GrimoireUsername } from "./GrimoireUsername";
import { isGrimoireMember } from "@/lib/grimoire-members";
export function QueryNip05({
pubkey,
@@ -20,6 +22,12 @@ export default function Nip05({
pubkey: string;
profile: ProfileContent;
}) {
// Check if this is a Grimoire member first - they get special display
if (isGrimoireMember(pubkey)) {
return <GrimoireUsername pubkey={pubkey} />;
}
// Otherwise show regular NIP-05 if available
if (!profile?.nip05) return null;
return <QueryNip05 pubkey={pubkey} nip05={profile.nip05} />;
}

View File

@@ -0,0 +1,135 @@
import { describe, it, expect } from "vitest";
import {
GRIMOIRE_MEMBERS,
isGrimoireMember,
getGrimoireMember,
getGrimoireMemberByNip05,
getGrimoireUsername,
getGrimoireNip05,
} from "./grimoire-members";
describe("Grimoire Members", () => {
const underscorePubkey =
"ce3cd5ba3ae52cec4e4b267fb29f1d2a526a5f4b8e8475d8a603a63c8925295f";
const verbirichaPubkey =
"7fa56f5d6962ab1e3cd424e758c3002b8665f7b0d8dcee9fe9e288d7751ac194";
const randomPubkey =
"0000000000000000000000000000000000000000000000000000000000000000";
describe("GRIMOIRE_MEMBERS", () => {
it("should contain exactly 2 members", () => {
expect(GRIMOIRE_MEMBERS).toHaveLength(2);
});
it("should have correct structure for all members", () => {
for (const member of GRIMOIRE_MEMBERS) {
expect(member).toHaveProperty("username");
expect(member).toHaveProperty("pubkey");
expect(member).toHaveProperty("nip05");
expect(typeof member.username).toBe("string");
expect(typeof member.pubkey).toBe("string");
expect(typeof member.nip05).toBe("string");
expect(member.pubkey).toHaveLength(64); // Hex pubkey
}
});
});
describe("isGrimoireMember", () => {
it("should return true for _ member", () => {
expect(isGrimoireMember(underscorePubkey)).toBe(true);
});
it("should return true for verbiricha member", () => {
expect(isGrimoireMember(verbirichaPubkey)).toBe(true);
});
it("should return false for non-member", () => {
expect(isGrimoireMember(randomPubkey)).toBe(false);
});
it("should be case-insensitive", () => {
expect(isGrimoireMember(underscorePubkey.toUpperCase())).toBe(true);
expect(isGrimoireMember(verbirichaPubkey.toUpperCase())).toBe(true);
});
});
describe("getGrimoireMember", () => {
it("should return member info for _ username", () => {
const member = getGrimoireMember(underscorePubkey);
expect(member).toBeDefined();
expect(member?.username).toBe("_");
expect(member?.pubkey).toBe(underscorePubkey);
expect(member?.nip05).toBe("_@grimoire.pro");
});
it("should return member info for verbiricha username", () => {
const member = getGrimoireMember(verbirichaPubkey);
expect(member).toBeDefined();
expect(member?.username).toBe("verbiricha");
expect(member?.pubkey).toBe(verbirichaPubkey);
expect(member?.nip05).toBe("verbiricha@grimoire.pro");
});
it("should return undefined for non-member", () => {
const member = getGrimoireMember(randomPubkey);
expect(member).toBeUndefined();
});
it("should be case-insensitive", () => {
const member = getGrimoireMember(underscorePubkey.toUpperCase());
expect(member).toBeDefined();
expect(member?.username).toBe("_");
});
});
describe("getGrimoireMemberByNip05", () => {
it("should return member info for _@grimoire.pro", () => {
const member = getGrimoireMemberByNip05("_@grimoire.pro");
expect(member).toBeDefined();
expect(member?.username).toBe("_");
expect(member?.pubkey).toBe(underscorePubkey);
});
it("should return member info for verbiricha@grimoire.pro", () => {
const member = getGrimoireMemberByNip05("verbiricha@grimoire.pro");
expect(member).toBeDefined();
expect(member?.username).toBe("verbiricha");
expect(member?.pubkey).toBe(verbirichaPubkey);
});
it("should return undefined for non-member identifier", () => {
const member = getGrimoireMemberByNip05("alice@example.com");
expect(member).toBeUndefined();
});
it("should be case-insensitive", () => {
const member = getGrimoireMemberByNip05("_@GRIMOIRE.PRO");
expect(member).toBeDefined();
expect(member?.username).toBe("_");
});
});
describe("getGrimoireUsername", () => {
it("should return username for member", () => {
expect(getGrimoireUsername(underscorePubkey)).toBe("_");
expect(getGrimoireUsername(verbirichaPubkey)).toBe("verbiricha");
});
it("should return undefined for non-member", () => {
expect(getGrimoireUsername(randomPubkey)).toBeUndefined();
});
});
describe("getGrimoireNip05", () => {
it("should return NIP-05 for member", () => {
expect(getGrimoireNip05(underscorePubkey)).toBe("_@grimoire.pro");
expect(getGrimoireNip05(verbirichaPubkey)).toBe(
"verbiricha@grimoire.pro",
);
});
it("should return undefined for non-member", () => {
expect(getGrimoireNip05(randomPubkey)).toBeUndefined();
});
});
});

View File

@@ -0,0 +1,96 @@
/**
* Grimoire Member System
*
* Defines special usernames for Grimoire project members with custom NIP-05 style identifiers.
* Members get special visual styling and verification badges.
*/
/**
* Grimoire member definition
*/
export interface GrimoireMember {
/** Username for display (e.g., "_", "verbiricha") */
username: string;
/** Hex pubkey */
pubkey: string;
/** NIP-05 style identifier (e.g., "_@grimoire.pro") */
nip05: string;
}
/**
* Official Grimoire project members
* These users get special username styling and verification badges
*/
export const GRIMOIRE_MEMBERS: readonly GrimoireMember[] = [
{
username: "_",
pubkey: "ce3cd5ba3ae52cec4e4b267fb29f1d2a526a5f4b8e8475d8a603a63c8925295f",
nip05: "_@grimoire.pro",
},
{
username: "verbiricha",
pubkey: "7fa56f5d6962ab1e3cd424e758c3002b8665f7b0d8dcee9fe9e288d7751ac194",
nip05: "verbiricha@grimoire.pro",
},
] as const;
/**
* Map of pubkey -> member for O(1) lookups
*/
const membersByPubkey = new Map<string, GrimoireMember>(
GRIMOIRE_MEMBERS.map((member) => [member.pubkey, member]),
);
/**
* Map of NIP-05 identifier -> member for O(1) lookups
*/
const membersByNip05 = new Map<string, GrimoireMember>(
GRIMOIRE_MEMBERS.map((member) => [member.nip05, member]),
);
/**
* Check if a pubkey belongs to a Grimoire member
* @param pubkey - Hex public key
* @returns true if user is a Grimoire member
*/
export function isGrimoireMember(pubkey: string): boolean {
return membersByPubkey.has(pubkey.toLowerCase());
}
/**
* Get Grimoire member info by pubkey
* @param pubkey - Hex public key
* @returns Member info or undefined
*/
export function getGrimoireMember(pubkey: string): GrimoireMember | undefined {
return membersByPubkey.get(pubkey.toLowerCase());
}
/**
* Get Grimoire member info by NIP-05 identifier
* @param nip05 - NIP-05 identifier (e.g., "_@grimoire.pro")
* @returns Member info or undefined
*/
export function getGrimoireMemberByNip05(
nip05: string,
): GrimoireMember | undefined {
return membersByNip05.get(nip05.toLowerCase());
}
/**
* Get Grimoire username for a pubkey
* @param pubkey - Hex public key
* @returns Username or undefined if not a member
*/
export function getGrimoireUsername(pubkey: string): string | undefined {
return membersByPubkey.get(pubkey.toLowerCase())?.username;
}
/**
* Get Grimoire NIP-05 identifier for a pubkey
* @param pubkey - Hex public key
* @returns NIP-05 identifier or undefined if not a member
*/
export function getGrimoireNip05(pubkey: string): string | undefined {
return membersByPubkey.get(pubkey.toLowerCase())?.nip05;
}

28
src/lib/nip05.test.ts Normal file
View File

@@ -0,0 +1,28 @@
import { describe, it, expect } from "vitest";
import { nip19 } from "nostr-tools";
describe("NIP-19 Decoding for Grimoire Members", () => {
it("should decode npub for _ username", () => {
const npub =
"npub1eras6w483zu6ee8kewfdm97n72fdkfd4e8ujgch0d3jfycfflwhsytskz0";
const decoded = nip19.decode(npub);
expect(decoded.type).toBe("npub");
expect(decoded.data).toBe(
"ce3cd5ba3ae52cec4e4b267fb29f1d2a526a5f4b8e8475d8a603a63c8925295f",
);
});
it("should decode nprofile for verbiricha username", () => {
const nprofile =
"nprofile1qy28wumn8ghj7mrfva58gmnfdenjuun9vshszxnhwden5te0wpuhyctdd9jzuenfv96x5ctx9e3k7mf0qydhwumn8ghj7argv4nx7un9wd6zumn0wd68yvfwvdhk6tcpz9mhxue69uhkummnw3ezuamfdejj7qpq07jk7htfv243u0x5ynn43scq9wrxtaasmrwwa8lfu2ydwag6cx2qsan62z";
const decoded = nip19.decode(nprofile);
expect(decoded.type).toBe("nprofile");
if (decoded.type === "nprofile") {
expect(decoded.data.pubkey).toBe(
"7fa56f5d6962ab1e3cd424e758c3002b8665f7b0d8dcee9fe9e288d7751ac194",
);
}
});
});