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", + ); + } + }); +});