diff --git a/README.md b/README.md index a3e9d96ae..5d149ea4e 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/components/icons.tsx b/src/components/icons.tsx index b0afaf828..3d8937c99 100644 --- a/src/components/icons.tsx +++ b/src/components/icons.tsx @@ -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, +}); diff --git a/src/components/note/index.tsx b/src/components/note/index.tsx index 9e1ecd162..e697cc122 100644 --- a/src/components/note/index.tsx +++ b/src/components/note/index.tsx @@ -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 ( - + + + {!isMobile && } {moment(event.created_at * 1000).fromNow()} diff --git a/src/components/user-dns-identity.tsx b/src/components/user-dns-identity.tsx new file mode 100644 index 000000000..9007f8546 --- /dev/null +++ b/src/components/user-dns-identity.tsx @@ -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 ; + } else if (error) { + return ; + } else { + const isValid = !!identity && pubkey === identity.pubkey; + return isValid ? : ; + } + }; + + return {renderIcon()}; +}; diff --git a/src/hooks/use-dns-identity.ts b/src/hooks/use-dns-identity.ts new file mode 100644 index 000000000..f19cc1ec5 --- /dev/null +++ b/src/hooks/use-dns-identity.ts @@ -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, + }; +} diff --git a/src/services/db/index.ts b/src/services/db/index.ts index c97a57ffb..af73b625c 100644 --- a/src/services/db/index.ts +++ b/src/services/db/index.ts @@ -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("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(); } diff --git a/src/services/db/schema.ts b/src/services/db/schema.ts index 8d5c375ec..cb9228060 100644 --- a/src/services/db/schema.ts +++ b/src/services/db/schema.ts @@ -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; diff --git a/src/services/dns-identity.ts b/src/services/dns-identity.ts new file mode 100644 index 000000000..15c5d9785 --- /dev/null +++ b/src/services/dns-identity.ts @@ -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; + relays?: Record; +}; +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); + + 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 + ); + + 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 | 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; diff --git a/src/types/nostr-event.ts b/src/types/nostr-event.ts index 16b19feea..e7ff41668 100644 --- a/src/types/nostr-event.ts +++ b/src/types/nostr-event.ts @@ -35,6 +35,7 @@ export type Kind0ParsedContent = { website?: string; lud16?: string; lud06?: string; + nip05?: string; }; export function isETag(tag: Tag): tag is ETag { diff --git a/src/views/user/index.tsx b/src/views/user/index.tsx index a6e1992ff..7be5cc894 100644 --- a/src/views/user/index.tsx +++ b/src/views/user/index.tsx @@ -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 = () => { - {getUserDisplayName(metadata, pubkey)} + + {getUserDisplayName(metadata, pubkey)} + + {!metadata ? : {metadata?.about}} {metadata?.website && (