diff --git a/src/components/nostr/GrimoireUsername.tsx b/src/components/nostr/GrimoireUsername.tsx
new file mode 100644
index 0000000..5a9fa37
--- /dev/null
+++ b/src/components/nostr/GrimoireUsername.tsx
@@ -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 (
+
+ {member.username}@grimoire.pro
+ {showIcon && (
+
+ )}
+
+ );
+}
+
+/**
+ * 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 (
+
+ );
+}
diff --git a/src/components/nostr/UserName.tsx b/src/components/nostr/UserName.tsx
index 551e86c..96e1ac2 100644
--- a/src/components/nostr/UserName.tsx
+++ b/src/components/nostr/UserName.tsx
@@ -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) {
- {isMention ? "@" : null}
- {displayName}
+
+ {isMention ? "@" : null}
+ {displayName}
+
+
);
}
diff --git a/src/components/nostr/nip05.tsx b/src/components/nostr/nip05.tsx
index d4c247d..a201216 100644
--- a/src/components/nostr/nip05.tsx
+++ b/src/components/nostr/nip05.tsx
@@ -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 ;
+ }
+
+ // Otherwise show regular NIP-05 if available
if (!profile?.nip05) return null;
return ;
}
diff --git a/src/lib/grimoire-members.test.ts b/src/lib/grimoire-members.test.ts
new file mode 100644
index 0000000..297a241
--- /dev/null
+++ b/src/lib/grimoire-members.test.ts
@@ -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();
+ });
+ });
+});
diff --git a/src/lib/grimoire-members.ts b/src/lib/grimoire-members.ts
new file mode 100644
index 0000000..b238599
--- /dev/null
+++ b/src/lib/grimoire-members.ts
@@ -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(
+ GRIMOIRE_MEMBERS.map((member) => [member.pubkey, member]),
+);
+
+/**
+ * Map of NIP-05 identifier -> member for O(1) lookups
+ */
+const membersByNip05 = new Map(
+ 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;
+}
diff --git a/src/lib/nip05.test.ts b/src/lib/nip05.test.ts
new file mode 100644
index 0000000..2e04a91
--- /dev/null
+++ b/src/lib/nip05.test.ts
@@ -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",
+ );
+ }
+ });
+});