diff --git a/src/components/ReqViewer.tsx b/src/components/ReqViewer.tsx
index 848962b..7aa45ea 100644
--- a/src/components/ReqViewer.tsx
+++ b/src/components/ReqViewer.tsx
@@ -97,6 +97,8 @@ interface ReqViewerProps {
view?: ViewMode;
nip05Authors?: string[];
nip05PTags?: string[];
+ domainAuthors?: string[];
+ domainPTags?: string[];
needsAccount?: boolean;
title?: string;
}
@@ -105,9 +107,16 @@ interface QueryDropdownProps {
filter: NostrFilter;
nip05Authors?: string[];
nip05PTags?: string[];
+ domainAuthors?: string[];
+ domainPTags?: string[];
}
-function QueryDropdown({ filter, nip05Authors }: QueryDropdownProps) {
+function QueryDropdown({
+ filter,
+ nip05Authors,
+ domainAuthors,
+ domainPTags,
+}: QueryDropdownProps) {
const { copy: handleCopy, copied } = useCopy();
// Expandable lists state
@@ -271,6 +280,13 @@ function QueryDropdown({ filter, nip05Authors }: QueryDropdownProps) {
))}
)}
+ {domainAuthors && domainAuthors.length > 0 && (
+
+ {domainAuthors.map((domain) => (
+
→ @{domain}
+ ))}
+
+ )}
@@ -311,6 +327,13 @@ function QueryDropdown({ filter, nip05Authors }: QueryDropdownProps) {
: `Show all ${pTagPubkeys.length}`}
)}
+ {domainPTags && domainPTags.length > 0 && (
+
+ {domainPTags.map((domain) => (
+
→ @{domain}
+ ))}
+
+ )}
@@ -610,6 +633,8 @@ export default function ReqViewer({
view = "list",
nip05Authors,
nip05PTags,
+ domainAuthors,
+ domainPTags,
needsAccount = false,
title = "nostr-events",
}: ReqViewerProps) {
@@ -1203,6 +1228,8 @@ export default function ReqViewer({
filter={resolvedFilter}
nip05Authors={nip05Authors}
nip05PTags={nip05PTags}
+ domainAuthors={domainAuthors}
+ domainPTags={domainPTags}
/>
)}
diff --git a/src/components/WindowRenderer.tsx b/src/components/WindowRenderer.tsx
index e43055e..6ec02c5 100644
--- a/src/components/WindowRenderer.tsx
+++ b/src/components/WindowRenderer.tsx
@@ -154,6 +154,8 @@ export function WindowRenderer({ window, onClose }: WindowRendererProps) {
view={window.props.view}
nip05Authors={window.props.nip05Authors}
nip05PTags={window.props.nip05PTags}
+ domainAuthors={window.props.domainAuthors}
+ domainPTags={window.props.domainPTags}
needsAccount={window.props.needsAccount}
/>
);
diff --git a/src/lib/count-parser.ts b/src/lib/count-parser.ts
index e8b5c50..35a91ec 100644
--- a/src/lib/count-parser.ts
+++ b/src/lib/count-parser.ts
@@ -1,6 +1,6 @@
import { nip19 } from "nostr-tools";
import type { NostrFilter } from "@/types/nostr";
-import { isNip05 } from "./nip05";
+import { isNip05, isDomain } from "./nip05";
import {
isValidHexPubkey,
isValidHexEventId,
@@ -14,6 +14,9 @@ export interface ParsedCountCommand {
nip05Authors?: string[];
nip05PTags?: string[];
nip05PTagsUppercase?: string[];
+ domainAuthors?: string[];
+ domainPTags?: string[];
+ domainPTagsUppercase?: string[];
needsAccount?: boolean;
}
@@ -61,6 +64,9 @@ export function parseCountCommand(args: string[]): ParsedCountCommand {
const nip05Authors = new Set();
const nip05PTags = new Set();
const nip05PTagsUppercase = new Set();
+ const domainAuthors = new Set();
+ const domainPTags = new Set();
+ const domainPTagsUppercase = new Set();
// Use sets for deduplication during accumulation
const kinds = new Set();
@@ -133,6 +139,12 @@ export function parseCountCommand(args: string[]): ParsedCountCommand {
if (normalized === "$me" || normalized === "$contacts") {
authors.add(normalized);
addedAny = true;
+ } else if (authorStr.startsWith("@")) {
+ const domain = authorStr.slice(1);
+ if (isDomain(domain)) {
+ domainAuthors.add(domain);
+ addedAny = true;
+ }
} else if (isNip05(authorStr)) {
nip05Authors.add(authorStr);
addedAny = true;
@@ -198,6 +210,12 @@ export function parseCountCommand(args: string[]): ParsedCountCommand {
if (normalized === "$me" || normalized === "$contacts") {
pTags.add(normalized);
addedAny = true;
+ } else if (pubkeyStr.startsWith("@")) {
+ const domain = pubkeyStr.slice(1);
+ if (isDomain(domain)) {
+ domainPTags.add(domain);
+ addedAny = true;
+ }
} else if (isNip05(pubkeyStr)) {
nip05PTags.add(pubkeyStr);
addedAny = true;
@@ -229,6 +247,12 @@ export function parseCountCommand(args: string[]): ParsedCountCommand {
if (normalized === "$me" || normalized === "$contacts") {
pTagsUppercase.add(normalized);
addedAny = true;
+ } else if (pubkeyStr.startsWith("@")) {
+ const domain = pubkeyStr.slice(1);
+ if (isDomain(domain)) {
+ domainPTagsUppercase.add(domain);
+ addedAny = true;
+ }
} else if (isNip05(pubkeyStr)) {
nip05PTagsUppercase.add(pubkeyStr);
addedAny = true;
@@ -377,6 +401,13 @@ export function parseCountCommand(args: string[]): ParsedCountCommand {
nip05PTagsUppercase.size > 0
? Array.from(nip05PTagsUppercase)
: undefined,
+ domainAuthors:
+ domainAuthors.size > 0 ? Array.from(domainAuthors) : undefined,
+ domainPTags: domainPTags.size > 0 ? Array.from(domainPTags) : undefined,
+ domainPTagsUppercase:
+ domainPTagsUppercase.size > 0
+ ? Array.from(domainPTagsUppercase)
+ : undefined,
needsAccount,
};
}
diff --git a/src/lib/nip05.ts b/src/lib/nip05.ts
index bba5870..5b3848b 100644
--- a/src/lib/nip05.ts
+++ b/src/lib/nip05.ts
@@ -98,3 +98,111 @@ export async function resolveNip05Batch(
return results;
}
+
+/**
+ * Domain Directory Resolution (@domain syntax)
+ * Resolves @domain to all pubkeys in domain's NIP-05 directory
+ */
+
+// Cache for domain directory lookups (domain -> {pubkeys, timestamp})
+const domainDirectoryCache = new Map<
+ string,
+ { pubkeys: string[]; timestamp: number }
+>();
+const DOMAIN_CACHE_TTL = 5 * 60 * 1000; // 5 minutes
+
+/**
+ * Check if a string looks like a domain (for @domain syntax)
+ */
+export function isDomain(value: string): boolean {
+ if (!value) return false;
+ return /^[a-zA-Z0-9][\w.-]+\.[a-zA-Z]{2,}$/.test(value);
+}
+
+/**
+ * Fetch all pubkeys from a domain's NIP-05 directory
+ * @param domain - Domain name (e.g., "habla.news")
+ * @returns Array of hex pubkeys from the domain's nostr.json
+ */
+export async function resolveDomainDirectory(
+ domain: string,
+): Promise {
+ // Normalize domain to lowercase
+ const normalizedDomain = domain.toLowerCase();
+
+ // Check cache first
+ const cached = domainDirectoryCache.get(normalizedDomain);
+ if (cached && Date.now() - cached.timestamp < DOMAIN_CACHE_TTL) {
+ console.log(`Domain directory cache hit for @${normalizedDomain}`);
+ return cached.pubkeys;
+ }
+
+ try {
+ const url = `https://${normalizedDomain}/.well-known/nostr.json`;
+ const response = await fetch(url, {
+ headers: { Accept: "application/json" },
+ signal: AbortSignal.timeout(5000), // 5s timeout
+ });
+
+ if (!response.ok) {
+ console.warn(
+ `Domain directory fetch failed for @${normalizedDomain}: ${response.status}`,
+ );
+ return [];
+ }
+
+ const data = await response.json();
+
+ if (!data.names || typeof data.names !== "object") {
+ console.warn(`Invalid nostr.json format for @${normalizedDomain}`);
+ return [];
+ }
+
+ // Extract all pubkeys from the names object
+ const pubkeys = Object.values(data.names)
+ .filter((pk): pk is string => typeof pk === "string")
+ .map((pk) => pk.toLowerCase());
+
+ console.log(
+ `Resolved @${normalizedDomain} → ${pubkeys.length} pubkeys`,
+ pubkeys.slice(0, 5),
+ );
+
+ // Cache the result
+ domainDirectoryCache.set(normalizedDomain, {
+ pubkeys,
+ timestamp: Date.now(),
+ });
+
+ return pubkeys;
+ } catch (error) {
+ console.warn(
+ `Domain directory resolution failed for @${normalizedDomain}:`,
+ error,
+ );
+ return [];
+ }
+}
+
+/**
+ * Resolve multiple domain directories in parallel
+ * @param domains - Array of domain names
+ * @returns Map of domain -> pubkeys array
+ */
+export async function resolveDomainDirectoryBatch(
+ domains: string[],
+): Promise