add nip-05 support

This commit is contained in:
hzrd149 2023-02-07 17:04:19 -06:00
parent fe0fd2197c
commit 62c0581cf2
10 changed files with 182 additions and 11 deletions

View File

@ -9,13 +9,13 @@
- [x] Lighting invoices
- [x] Blurred or hidden images and embeds for people you dont follow
- [x] Thread view
- [x] NIP-05 support
- [x] Broadcast events
- [x] User tipping
- [ ] Manage followers ( Contact List )
- [ ] Profile management
- [ ] Relay management
- [ ] NIP-05 support
- [x] Broadcast events
- [ ] Image upload
- [x] User tipping
- [ ] Reactions
- [ ] Dynamically connect to relays (start with one relay then connect to others as required)
- [ ] Reporting users and events
@ -27,7 +27,7 @@
- [ ] [NIP-02](https://github.com/nostr-protocol/nips/blob/master/02.md): Contact List and Petnames
- [ ] [NIP-03](https://github.com/nostr-protocol/nips/blob/master/03.md): OpenTimestamps Attestations for Events
- [ ] [NIP-04](https://github.com/nostr-protocol/nips/blob/master/04.md): Encrypted Direct Message
- [ ] [NIP-05](https://github.com/nostr-protocol/nips/blob/master/05.md): Mapping Nostr keys to DNS-based internet identifiers
- [x] [NIP-05](https://github.com/nostr-protocol/nips/blob/master/05.md): Mapping Nostr keys to DNS-based internet identifiers
- [ ] [NIP-06](https://github.com/nostr-protocol/nips/blob/master/06.md): Basic key derivation from mnemonic seed phrase
- [ ] [NIP-07](https://github.com/nostr-protocol/nips/blob/master/07.md): `window.nostr` capability for web browsers
- [ ] [NIP-08](https://github.com/nostr-protocol/nips/blob/master/08.md): Handling Mentions
@ -64,6 +64,7 @@
- massive thread note1dapvuu8fl09yjtg2gyr2h6nypaffl2sq0aj5raz86463qk5kpyzqlxvtc3
- sort replies by date
- filter list of followers by users the user has blocked/reported (stops bots/spammers from showing up at followers)
- Add client side relay groups
## Setup

View File

@ -15,7 +15,7 @@ export const IMAGE_ICONS = {
const defaultProps: IconProps = { fontSize: "1.2em" };
export const GlobalIcon = createIcon({
displayName: "global-line",
displayName: "global-icon",
d: "M12 22C6.477 22 2 17.523 2 12S6.477 2 12 2s10 4.477 10 10-4.477 10-10 10zm-2.29-2.333A17.9 17.9 0 0 1 8.027 13H4.062a8.008 8.008 0 0 0 5.648 6.667zM10.03 13c.151 2.439.848 4.73 1.97 6.752A15.905 15.905 0 0 0 13.97 13h-3.94zm9.908 0h-3.965a17.9 17.9 0 0 1-1.683 6.667A8.008 8.008 0 0 0 19.938 13zM4.062 11h3.965A17.9 17.9 0 0 1 9.71 4.333 8.008 8.008 0 0 0 4.062 11zm5.969 0h3.938A15.905 15.905 0 0 0 12 4.248 15.905 15.905 0 0 0 10.03 11zm4.259-6.667A17.9 17.9 0 0 1 15.973 11h3.965a8.008 8.008 0 0 0-5.648-6.667z",
defaultProps,
});
@ -27,13 +27,13 @@ export const FeedIcon = createIcon({
});
export const MoreIcon = createIcon({
displayName: "more-line",
displayName: "more-icon",
d: "M4.5 10.5c-.825 0-1.5.675-1.5 1.5s.675 1.5 1.5 1.5S6 12.825 6 12s-.675-1.5-1.5-1.5zm15 0c-.825 0-1.5.675-1.5 1.5s.675 1.5 1.5 1.5S21 12.825 21 12s-.675-1.5-1.5-1.5zm-7.5 0c-.825 0-1.5.675-1.5 1.5s.675 1.5 1.5 1.5 1.5-.675 1.5-1.5-.675-1.5-1.5-1.5z",
defaultProps,
});
export const CodeIcon = createIcon({
displayName: "code-line",
displayName: "code-icon",
d: `M23 12l-7.071 7.071-1.414-1.414L20.172 12l-5.657-5.657 1.414-1.414L23 12zM3.828 12l5.657 5.657-1.414 1.414L1 12l7.071-7.071 1.414 1.414L3.828 12z`,
defaultProps,
});
@ -139,3 +139,15 @@ export const ReplyIcon = createIcon({
d: "M11 20L1 12l10-8v5c5.523 0 10 4.477 10 10 0 .273-.01.543-.032.81-1.463-2.774-4.33-4.691-7.655-4.805L13 15h-2v5zm-2-7h4.034l.347.007c1.285.043 2.524.31 3.676.766C15.59 12.075 13.42 11 11 11H9V8.161L4.202 12 9 15.839V13z",
defaultProps,
});
export const VerifiedIcon = createIcon({
displayName: "VerifiedIcon",
d: "M12 22C6.477 22 2 17.523 2 12S6.477 2 12 2s10 4.477 10 10-4.477 10-10 10zm-.997-6l7.07-7.071-1.414-1.414-5.656 5.657-2.829-2.829-1.414 1.414L11.003 16z",
defaultProps,
});
export const VerificationFailed = createIcon({
displayName: "VerificationFailed",
d: "M12 22C6.477 22 2 17.523 2 12S6.477 2 12 2s10 4.477 10 10-4.477 10-10 10zm-1-7v2h2v-2h-2zm0-8v6h2V7h-2z",
defaultProps,
});

View File

@ -1,7 +1,7 @@
import React, { useContext } from "react";
import { Link as RouterLink } from "react-router-dom";
import moment from "moment";
import { Box, Card, CardBody, CardFooter, CardHeader, Flex, Heading, IconButton, Link } from "@chakra-ui/react";
import { Box, Card, CardBody, CardFooter, CardHeader, Flex, Heading, IconButton, Link, Text } from "@chakra-ui/react";
import { NostrEvent } from "../../types/nostr-event";
import { UserAvatarLink } from "../user-avatar-link";
import { Bech32Prefix, normalizeToBech32 } from "../../helpers/nip-19";
@ -18,6 +18,7 @@ import { UserLink } from "../user-link";
import { ReplyIcon } from "../icons";
import { PostModalContext } from "../../providers/post-modal-provider";
import { buildReply } from "../../helpers/nostr-event";
import { UserDnsIdentityIcon } from "../user-dns-identity";
export type NoteProps = {
event: NostrEvent;
@ -34,13 +35,15 @@ export const Note = React.memo(({ event }: NoteProps) => {
return (
<Card variant="outline">
<CardHeader padding="2" mb="2">
<CardHeader padding="2">
<Flex flex="1" gap="2" alignItems="center" wrap="wrap">
<UserAvatarLink pubkey={event.pubkey} size={isMobile ? "xs" : "sm"} />
<Heading size="sm" display="inline">
<UserLink pubkey={event.pubkey} />
</Heading>
<UserDnsIdentityIcon pubkey={event.pubkey} />
{!isMobile && <Flex grow={1} />}
<Link as={RouterLink} to={`/n/${normalizeToBech32(event.id, Bech32Prefix.Note)}`} whiteSpace="nowrap">
{moment(event.created_at * 1000).fromNow()}
</Link>

View File

@ -0,0 +1,28 @@
import { Spinner, Tooltip } from "@chakra-ui/react";
import { useDnsIdentity } from "../hooks/use-dns-identity";
import { useUserMetadata } from "../hooks/use-user-metadata";
import { VerificationFailed, VerifiedIcon } from "./icons";
export const UserDnsIdentityIcon = ({ pubkey }: { pubkey: string }) => {
const metadata = useUserMetadata(pubkey);
const { identity, loading, error } = useDnsIdentity(metadata?.nip05);
if (!metadata?.nip05) {
return null;
}
let title = metadata.nip05;
const renderIcon = () => {
if (loading) {
return <Spinner size="xs" ml="1" />;
} else if (error) {
return <VerificationFailed color="yellow.500" />;
} else {
const isValid = !!identity && pubkey === identity.pubkey;
return isValid ? <VerifiedIcon color="purple.500" /> : <VerificationFailed color="red.500" />;
}
};
return <Tooltip label={title}>{renderIcon()}</Tooltip>;
};

View File

@ -0,0 +1,15 @@
import { useAsync } from "react-use";
import dnsIdentityService from "../services/dns-identity";
export function useDnsIdentity(address: string | undefined) {
const { value, loading, error } = useAsync(async () => {
if (!address) return;
return dnsIdentityService.getIdentity(address);
}, [address]);
return {
identity: value,
error,
loading,
};
}

View File

@ -22,6 +22,12 @@ const MIGRATIONS: MigrationFunction[] = [
contacts.createIndex("created_at", "created_at");
contacts.createIndex("contacts", "contacts", { multiEntry: true });
const dnsIdentifiers = db.createObjectStore("dns-identifiers");
dnsIdentifiers.createIndex("pubkey", "pubkey", { unique: false });
dnsIdentifiers.createIndex("name", "name", { unique: false });
dnsIdentifiers.createIndex("domain", "domain", { unique: false });
dnsIdentifiers.createIndex("updated", "updated", { unique: false });
// setup data
const settings = db.createObjectStore("settings");
},
@ -44,7 +50,7 @@ const db = await openDB<CustomSchema>("storage", version, {
export async function clearData() {
await db.clear("user-metadata");
await db.clear("user-contacts");
await db.clear("settings");
await db.clear("dns-identifiers");
window.location.reload();
}

View File

@ -20,6 +20,11 @@ export interface CustomSchema extends DBSchema {
};
indexes: { created_at: number; contacts: string };
};
"dns-identifiers": {
key: string;
value: { name: string; domain: string; pubkey: string; relays: string[]; updated: number };
indexes: { name: string; domain: string; pubkey: string; updated: number };
};
settings: {
key: string;
value: any;

View File

@ -0,0 +1,96 @@
import moment from "moment";
import db from "./db";
function parseAddress(address: string) {
const parts = address.split("@");
return { name: parts[0], domain: parts[1] };
}
type IdentityJson = {
names: Record<string, string | undefined>;
relays?: Record<string, string[]>;
};
export type DnsIdentity = {
name: string;
domain: string;
pubkey: string;
relays: string[];
};
function getIdentityFromJson(name: string, domain: string, json: IdentityJson): DnsIdentity | undefined {
const relays: string[] = json.relays?.[name] ?? [];
const pubkey = json.names[name];
if (pubkey) return { name, domain, pubkey, relays };
}
async function fetchAllIdentities(domain: string) {
const json = await fetch(`//${domain}/.well-known/nostr.json`).then((res) => res.json() as Promise<IdentityJson>);
await addToCache(domain, json);
}
async function fetchIdentity(address: string) {
const { name, domain } = parseAddress(address);
const json = await fetch(`https://${domain}/.well-known/nostr.json?name=${name}`).then(
(res) => res.json() as Promise<IdentityJson>
);
await addToCache(domain, json);
return getIdentityFromJson(name, domain, json);
}
async function addToCache(domain: string, json: IdentityJson) {
const now = moment().unix();
const wait = [];
for (const name of Object.keys(json.names)) {
const identity = getIdentityFromJson(name, domain, json);
if (identity) {
wait.push(db.put("dns-identifiers", { ...identity, updated: now }, `${name}@${domain}`));
}
}
await Promise.all(wait);
}
async function getIdentity(address: string) {
const cached = await db.get("dns-identifiers", address);
if (cached) return cached;
// TODO: if it fails, maybe cache a failure message
return fetchIdentity(address);
}
async function pruneCache() {
const keys = await db.getAllKeysFromIndex(
"dns-identifiers",
"updated",
IDBKeyRange.upperBound(moment().subtract(1, "hour").unix())
);
for (const pubkey of keys) {
db.delete("dns-identifiers", pubkey);
}
}
const pending: Record<string, ReturnType<typeof getIdentity> | undefined> = {};
function dedupedGetIdentity(address: string) {
if (pending[address]) return pending[address];
return (pending[address] = getIdentity(address));
}
export const dnsIdentityService = {
fetchAllIdentities,
fetchIdentity,
getIdentity: dedupedGetIdentity,
pruneCache,
};
setTimeout(() => pruneCache(), 1000 * 60 * 20);
if (import.meta.env.DEV) {
// @ts-ignore
window.dnsIdentityService = dnsIdentityService;
}
export default dnsIdentityService;

View File

@ -35,6 +35,7 @@ export type Kind0ParsedContent = {
website?: string;
lud16?: string;
lud06?: string;
nip05?: string;
};
export function isETag(tag: Tag): tag is ETag {

View File

@ -20,6 +20,7 @@ import { useIsMobile } from "../../hooks/use-is-mobile";
import { UserProfileMenu } from "./components/user-profile-menu";
import { LinkIcon } from "@chakra-ui/icons";
import { UserTipButton } from "../../components/user-tip-button";
import { UserDnsIdentityIcon } from "../../components/user-dns-identity";
const tabs = [
{ label: "Notes", path: "notes" },
@ -45,7 +46,10 @@ const UserView = () => {
<Flex gap="4" padding="2">
<UserAvatar pubkey={pubkey} size={isMobile ? "md" : "xl"} />
<Flex direction="column" gap={isMobile ? 0 : 2}>
<Heading size={isMobile ? "md" : "lg"}>{getUserDisplayName(metadata, pubkey)}</Heading>
<Heading size={isMobile ? "md" : "lg"}>
{getUserDisplayName(metadata, pubkey)}
<UserDnsIdentityIcon pubkey={pubkey} />
</Heading>
{!metadata ? <SkeletonText /> : <Text>{metadata?.about}</Text>}
{metadata?.website && (
<Text>