mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-12 16:37:06 +02:00
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:
72
src/components/nostr/GrimoireUsername.tsx
Normal file
72
src/components/nostr/GrimoireUsername.tsx
Normal 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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
135
src/lib/grimoire-members.test.ts
Normal file
135
src/lib/grimoire-members.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
96
src/lib/grimoire-members.ts
Normal file
96
src/lib/grimoire-members.ts
Normal 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
28
src/lib/nip05.test.ts
Normal 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",
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user