├── .eslintrc.json ├── .gitattributes ├── .github └── workflows │ ├── main.yml │ └── size.yml ├── .gitignore ├── .prettierrc.json ├── LICENSE ├── README.md ├── package.json ├── src ├── core.tsx ├── index.tsx ├── useProfile.tsx └── utils.ts ├── tsconfig.json └── yarn.lock /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es2021": true 5 | }, 6 | "extends": [ 7 | "eslint:recommended", 8 | "plugin:react/jsx-runtime", 9 | "plugin:@typescript-eslint/recommended", 10 | "prettier" 11 | ], 12 | "overrides": [], 13 | "parser": "@typescript-eslint/parser", 14 | "parserOptions": { 15 | "ecmaVersion": "latest", 16 | "sourceType": "module" 17 | }, 18 | "plugins": [ 19 | "react", 20 | "@typescript-eslint", 21 | "prettier" 22 | ], 23 | "rules": { 24 | "prettier/prettier": "warn" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text eol=lf 2 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: [push] 3 | jobs: 4 | build: 5 | name: Build, lint, and test on Node ${{ matrix.node }} and ${{ matrix.os }} 6 | 7 | runs-on: ${{ matrix.os }} 8 | strategy: 9 | matrix: 10 | node: ['14.x', '16.x'] 11 | os: [ubuntu-latest, windows-latest, macOS-latest] 12 | 13 | steps: 14 | - name: Checkout repo 15 | uses: actions/checkout@v3 16 | 17 | - name: Use Node ${{ matrix.node }} 18 | uses: actions/setup-node@v3 19 | with: 20 | node-version: ${{ matrix.node }} 21 | 22 | - name: Install deps and build (with cache) 23 | uses: bahmutov/npm-install@v1 24 | 25 | - name: Lint 26 | run: yarn lint 27 | 28 | - name: Test 29 | run: yarn test --ci --coverage --maxWorkers=2 30 | 31 | - name: Build 32 | run: yarn build 33 | -------------------------------------------------------------------------------- /.github/workflows/size.yml: -------------------------------------------------------------------------------- 1 | name: size 2 | on: [pull_request] 3 | jobs: 4 | size: 5 | runs-on: ubuntu-latest 6 | env: 7 | CI_JOB_NUMBER: 1 8 | steps: 9 | - uses: actions/checkout@v3 10 | - uses: andresz1/size-limit-action@v1 11 | with: 12 | github_token: ${{ secrets.GITHUB_TOKEN }} 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.log 2 | .DS_Store 3 | node_modules 4 | .cache 5 | dist 6 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "tabWidth": 2, 4 | "trailingComma": "all", 5 | "arrowParens": "always" 6 | } 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Tristan Edwards 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | nostr-react 3 |

4 |

5 | React Hooks for Nostr ✨ 6 |

7 | 8 | ## Installation 9 | 10 | ``` 11 | npm install nostr-react 12 | ``` 13 | 14 | ## Example usage: 15 | 16 | Wrap your app in the NostrProvider: 17 | 18 | ```tsx 19 | import { NostrProvider } from "nostr-react"; 20 | 21 | const relayUrls = [ 22 | "wss://nostr-pub.wellorder.net", 23 | "wss://relay.nostr.ch", 24 | ]; 25 | 26 | function MyApp() { 27 | return ( 28 | 29 | 30 | 31 | ); 32 | }; 33 | ``` 34 | 35 | You can now use the `useNostr` and `useNostrEvents` hooks in your components! 36 | 37 | **Fetching all `text_note` events starting now:** 38 | 39 | ```tsx 40 | import { useRef } from "react"; 41 | import { useNostrEvents, dateToUnix } from "nostr-react"; 42 | 43 | const GlobalFeed = () => { 44 | const now = useRef(new Date()); // Make sure current time isn't re-rendered 45 | 46 | const { events } = useNostrEvents({ 47 | filter: { 48 | since: dateToUnix(now.current), // all new events from now 49 | kinds: [1], 50 | }, 51 | }); 52 | 53 | return ( 54 | <> 55 | {events.map((event) => ( 56 |

{event.pubkey} posted: {event.content}

57 | ))} 58 | 59 | ); 60 | }; 61 | ``` 62 | 63 | **Fetching all `text_note` events from a specific user, since the beginning of time:** 64 | 65 | ```tsx 66 | import { useNostrEvents } from "nostr-react"; 67 | 68 | const ProfileFeed = () => { 69 | const { events } = useNostrEvents({ 70 | filter: { 71 | authors: [ 72 | "9c2a6495b4e3de93f3e1cc254abe4078e17c64e5771abc676a5e205b62b1286c", 73 | ], 74 | since: 0, 75 | kinds: [1], 76 | }, 77 | }); 78 | 79 | return ( 80 | <> 81 | {events.map((event) => ( 82 |

{event.pubkey} posted: {event.content}

83 | ))} 84 | 85 | ); 86 | }; 87 | ``` 88 | 89 | **Fetching user profiles** 90 | 91 | Use the `useProfile` hook to render user profiles. You can use this in multiple components at once (for example, rendering a name and avatar for each message in a chat), the hook will automatically use *batching* to prevent errors where a client sends too many requests at once. 🎉 92 | 93 | ```tsx 94 | import { useProfile } from "nostr-react"; 95 | 96 | const Profile = () => { 97 | const { data: userData } = useProfile({ 98 | pubkey, 99 | }); 100 | 101 | return ( 102 | <> 103 |

Name: {userData?.name}

104 |

Public key: {userData?.npub}

105 |

Picture URL: {userData?.picture}

106 | 107 | ) 108 | } 109 | ``` 110 | 111 | **Post a message:** 112 | 113 | ```tsx 114 | import { useNostr, dateToUnix } from "nostr-react"; 115 | 116 | import { 117 | type Event as NostrEvent, 118 | getEventHash, 119 | getPublicKey, 120 | signEvent, 121 | } from "nostr-tools"; 122 | 123 | export default function PostButton() { 124 | const { publish } = useNostr(); 125 | 126 | const onPost = async () => { 127 | const privKey = prompt("Paste your private key:"); 128 | 129 | if (!privKey) { 130 | alert("no private key provided"); 131 | return; 132 | } 133 | 134 | const message = prompt("Enter the message you want to send:"); 135 | 136 | if (!message) { 137 | alert("no message provided"); 138 | return; 139 | } 140 | 141 | const event: NostrEvent = { 142 | content: message, 143 | kind: 1, 144 | tags: [], 145 | created_at: dateToUnix(), 146 | pubkey: getPublicKey(privKey), 147 | }; 148 | 149 | event.id = getEventHash(event); 150 | event.sig = signEvent(event, privKey); 151 | 152 | publish(event); 153 | }; 154 | 155 | return ( 156 | 157 | ); 158 | } 159 | ``` 160 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nostr-react", 3 | "version": "0.7.0", 4 | "license": "MIT", 5 | "author": "t4t5 (https://t4t5.xyz)", 6 | "main": "dist/index.js", 7 | "module": "dist/nostr-react.esm.js", 8 | "typings": "dist/index.d.ts", 9 | "files": [ 10 | "dist", 11 | "src" 12 | ], 13 | "scripts": { 14 | "analyze": "size-limit --why", 15 | "build": "dts build", 16 | "lint": "dts lint", 17 | "prepare": "dts build", 18 | "size": "size-limit", 19 | "start": "dts watch", 20 | "test": "dts test --passWithNoTests" 21 | }, 22 | "repository": { 23 | "type": "git", 24 | "url": "https://github.com/t4t5/nostr-react" 25 | }, 26 | "husky": { 27 | "hooks": { 28 | "pre-commit": "dts lint" 29 | } 30 | }, 31 | "jest": { 32 | "testEnvironment": "jsdom" 33 | }, 34 | "peerDependencies": { 35 | "react": ">=16" 36 | }, 37 | "engines": { 38 | "node": ">=12" 39 | }, 40 | "size-limit": [ 41 | { 42 | "path": "dist/nostr-react.cjs.production.min.js", 43 | "limit": "10 KB" 44 | }, 45 | { 46 | "path": "dist/nostr-react.esm.js", 47 | "limit": "10 KB" 48 | } 49 | ], 50 | "devDependencies": { 51 | "@size-limit/preset-small-lib": "^8.1.0", 52 | "@tsconfig/create-react-app": "^1.0.3", 53 | "@tsconfig/recommended": "^1.0.1", 54 | "@types/react": "^18.0.26", 55 | "@types/react-dom": "^18.0.9", 56 | "@typescript-eslint/eslint-plugin": "^5.47.0", 57 | "@typescript-eslint/parser": "^5.47.0", 58 | "dts-cli": "^1.6.0", 59 | "eslint": "^8.30.0", 60 | "eslint-config-prettier": "^8.5.0", 61 | "eslint-plugin-prettier": "^4.2.1", 62 | "eslint-plugin-react": "^7.31.11", 63 | "husky": "^8.0.2", 64 | "react": "^18.2.0", 65 | "react-dom": "^18.2.0", 66 | "size-limit": "^8.1.0", 67 | "tslib": "^2.4.1", 68 | "typescript": "^4.9.4" 69 | }, 70 | "dependencies": { 71 | "jotai": "^1.12.1", 72 | "nostr-tools": "^1.1.0" 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/core.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | createContext, 3 | ReactNode, 4 | useCallback, 5 | useContext, 6 | useEffect, 7 | useRef, 8 | useState, 9 | } from "react" 10 | 11 | import { Relay, Filter, Event as NostrEvent, relayInit, Sub } from "nostr-tools" 12 | 13 | import { uniqBy } from "./utils" 14 | 15 | type OnConnectFunc = (relay: Relay) => void 16 | type OnDisconnectFunc = (relay: Relay) => void 17 | type OnEventFunc = (event: NostrEvent) => void 18 | type OnDoneFunc = () => void 19 | type OnSubscribeFunc = (sub: Sub, relay: Relay) => void 20 | 21 | interface NostrContextType { 22 | isLoading: boolean 23 | debug?: boolean 24 | connectedRelays: Relay[] 25 | onConnect: (_onConnectCallback?: OnConnectFunc) => void 26 | onDisconnect: (_onDisconnectCallback?: OnDisconnectFunc) => void 27 | publish: (event: NostrEvent) => void 28 | } 29 | 30 | const NostrContext = createContext({ 31 | isLoading: true, 32 | connectedRelays: [], 33 | onConnect: () => null, 34 | onDisconnect: () => null, 35 | publish: () => null, 36 | }) 37 | 38 | const log = ( 39 | isOn: boolean | undefined, 40 | type: "info" | "error" | "warn", 41 | ...args: unknown[] 42 | ) => { 43 | if (!isOn) return 44 | console[type](...args) 45 | } 46 | 47 | export function NostrProvider({ 48 | children, 49 | relayUrls, 50 | debug, 51 | }: { 52 | children: ReactNode 53 | relayUrls: string[] 54 | debug?: boolean 55 | }) { 56 | const [isLoading, setIsLoading] = useState(true) 57 | const [connectedRelays, setConnectedRelays] = useState([]) 58 | const [relays, setRelays] = useState([]) 59 | const relayUrlsRef = useRef([]) 60 | 61 | let onConnectCallback: null | OnConnectFunc = null 62 | let onDisconnectCallback: null | OnDisconnectFunc = null 63 | 64 | const disconnectToRelays = useCallback( 65 | (relayUrls: string[]) => { 66 | relayUrls.forEach(async (relayUrl) => { 67 | await relays.find((relay) => relay.url === relayUrl)?.close() 68 | setRelays((prev) => prev.filter((r) => r.url !== relayUrl)) 69 | }) 70 | }, 71 | [relays], 72 | ) 73 | 74 | const connectToRelays = useCallback( 75 | (relayUrls: string[]) => { 76 | relayUrls.forEach(async (relayUrl) => { 77 | const relay = relayInit(relayUrl) 78 | 79 | if (connectedRelays.findIndex((r) => r.url === relayUrl) >= 0) { 80 | // already connected, skip 81 | return 82 | } 83 | 84 | setRelays((prev) => uniqBy([...prev, relay], "url")) 85 | relay.connect() 86 | 87 | relay.on("connect", () => { 88 | log(debug, "info", `✅ nostr (${relayUrl}): Connected!`) 89 | setIsLoading(false) 90 | onConnectCallback?.(relay) 91 | setConnectedRelays((prev) => uniqBy([...prev, relay], "url")) 92 | }) 93 | 94 | relay.on("disconnect", () => { 95 | log(debug, "warn", `🚪 nostr (${relayUrl}): Connection closed.`) 96 | onDisconnectCallback?.(relay) 97 | setConnectedRelays((prev) => prev.filter((r) => r.url !== relayUrl)) 98 | }) 99 | 100 | relay.on("error", () => { 101 | log(debug, "error", `❌ nostr (${relayUrl}): Connection error!`) 102 | }) 103 | }) 104 | }, 105 | [connectedRelays, debug, onConnectCallback, onDisconnectCallback], 106 | ) 107 | 108 | useEffect(() => { 109 | if (relayUrlsRef.current === relayUrls) { 110 | // relayUrls isn't updated, skip 111 | return 112 | } 113 | 114 | const relayUrlsToDisconnect = relayUrlsRef.current.filter( 115 | (relayUrl) => !relayUrls.includes(relayUrl), 116 | ) 117 | 118 | disconnectToRelays(relayUrlsToDisconnect) 119 | connectToRelays(relayUrls) 120 | 121 | relayUrlsRef.current = relayUrls 122 | }, [relayUrls, connectToRelays, disconnectToRelays]) 123 | 124 | const publish = (event: NostrEvent) => { 125 | return connectedRelays.map((relay) => { 126 | log(debug, "info", `⬆️ nostr (${relay.url}): Sending event:`, event) 127 | 128 | return relay.publish(event) 129 | }) 130 | } 131 | 132 | const value: NostrContextType = { 133 | debug, 134 | isLoading, 135 | connectedRelays, 136 | publish, 137 | onConnect: (_onConnectCallback?: OnConnectFunc) => { 138 | if (_onConnectCallback) { 139 | onConnectCallback = _onConnectCallback 140 | } 141 | }, 142 | onDisconnect: (_onDisconnectCallback?: OnDisconnectFunc) => { 143 | if (_onDisconnectCallback) { 144 | onDisconnectCallback = _onDisconnectCallback 145 | } 146 | }, 147 | } 148 | 149 | return {children} 150 | } 151 | 152 | export function useNostr() { 153 | return useContext(NostrContext) 154 | } 155 | 156 | export function useNostrEvents({ 157 | filter, 158 | enabled = true, 159 | }: { 160 | filter: Filter 161 | enabled?: boolean 162 | }) { 163 | const { 164 | isLoading: isLoadingProvider, 165 | onConnect, 166 | debug, 167 | connectedRelays, 168 | } = useNostr() 169 | 170 | const [isLoading, setIsLoading] = useState(true) 171 | const [events, setEvents] = useState([]) 172 | const [unsubscribe, setUnsubscribe] = useState<() => void | void>(() => { 173 | return 174 | }) 175 | 176 | let onEventCallback: null | OnEventFunc = null 177 | let onSubscribeCallback: null | OnSubscribeFunc = null 178 | let onDoneCallback: null | OnDoneFunc = null 179 | 180 | // Lets us detect changes in the nested filter object for the useEffect hook 181 | const filterBase64 = 182 | typeof window !== "undefined" ? window.btoa(JSON.stringify(filter)) : null 183 | 184 | const _unsubscribe = (sub: Sub, relay: Relay) => { 185 | log( 186 | debug, 187 | "info", 188 | `🙉 nostr (${relay.url}): Unsubscribing from filter:`, 189 | filter, 190 | ) 191 | return sub.unsub() 192 | } 193 | 194 | const subscribe = useCallback((relay: Relay, filter: Filter) => { 195 | log( 196 | debug, 197 | "info", 198 | `👂 nostr (${relay.url}): Subscribing to filter:`, 199 | filter, 200 | ) 201 | const sub = relay.sub([filter]) 202 | 203 | setIsLoading(true) 204 | 205 | const unsubscribeFunc = () => { 206 | _unsubscribe(sub, relay) 207 | } 208 | 209 | setUnsubscribe(() => unsubscribeFunc) 210 | 211 | sub.on("event", (event: NostrEvent) => { 212 | log(debug, "info", `⬇️ nostr (${relay.url}): Received event:`, event) 213 | onEventCallback?.(event) 214 | setEvents((_events) => { 215 | return [event, ..._events] 216 | }) 217 | }) 218 | 219 | sub.on("eose", () => { 220 | setIsLoading(false) 221 | onDoneCallback?.() 222 | }) 223 | 224 | return sub 225 | }, []) 226 | 227 | useEffect(() => { 228 | if (!enabled) return 229 | 230 | const relaySubs = connectedRelays.map((relay) => { 231 | const sub = subscribe(relay, filter) 232 | 233 | onSubscribeCallback?.(sub, relay) 234 | 235 | return { 236 | sub, 237 | relay, 238 | } 239 | }) 240 | 241 | return () => { 242 | relaySubs.forEach(({ sub, relay }) => { 243 | _unsubscribe(sub, relay) 244 | }) 245 | } 246 | }, [connectedRelays, filterBase64, enabled]) 247 | 248 | const uniqEvents = events.length > 0 ? uniqBy(events, "id") : [] 249 | const sortedEvents = uniqEvents.sort((a, b) => b.created_at - a.created_at) 250 | 251 | return { 252 | isLoading: isLoading || isLoadingProvider, 253 | events: sortedEvents, 254 | onConnect, 255 | connectedRelays, 256 | unsubscribe, 257 | onSubscribe: (_onSubscribeCallback: OnSubscribeFunc) => { 258 | if (_onSubscribeCallback) { 259 | onSubscribeCallback = _onSubscribeCallback 260 | } 261 | }, 262 | onEvent: (_onEventCallback: OnEventFunc) => { 263 | if (_onEventCallback) { 264 | onEventCallback = _onEventCallback 265 | } 266 | }, 267 | onDone: (_onDoneCallback: OnDoneFunc) => { 268 | if (_onDoneCallback) { 269 | onDoneCallback = _onDoneCallback 270 | } 271 | }, 272 | } 273 | } 274 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | export * from "./utils" 2 | export * from "./core" 3 | export * from "./useProfile" 4 | -------------------------------------------------------------------------------- /src/useProfile.tsx: -------------------------------------------------------------------------------- 1 | import { atom, useAtom } from "jotai" 2 | import { nip19 } from "nostr-tools" 3 | import { useEffect, useState } from "react" 4 | 5 | import { useNostrEvents } from "./core" 6 | import { uniqValues } from "./utils" 7 | 8 | export interface Metadata { 9 | name?: string 10 | username?: string 11 | display_name?: string 12 | picture?: string 13 | banner?: string 14 | about?: string 15 | website?: string 16 | lud06?: string 17 | lud16?: string 18 | nip05?: string 19 | } 20 | 21 | const QUEUE_DEBOUNCE_DURATION = 100 22 | 23 | let timer: NodeJS.Timeout | undefined = undefined 24 | 25 | const queuedPubkeysAtom = atom([]) 26 | const requestedPubkeysAtom = atom([]) 27 | const fetchedProfilesAtom = atom>({}) 28 | 29 | function useProfileQueue({ pubkey }: { pubkey: string }) { 30 | const [isReadyToFetch, setIsReadyToFetch] = useState(false) 31 | 32 | const [queuedPubkeys, setQueuedPubkeys] = useAtom(queuedPubkeysAtom) 33 | 34 | const [requestedPubkeys] = useAtom(requestedPubkeysAtom) 35 | const alreadyRequested = !!requestedPubkeys.includes(pubkey) 36 | 37 | useEffect(() => { 38 | if (alreadyRequested) { 39 | return 40 | } 41 | 42 | clearTimeout(timer) 43 | 44 | timer = setTimeout(() => { 45 | setIsReadyToFetch(true) 46 | }, QUEUE_DEBOUNCE_DURATION) 47 | 48 | setQueuedPubkeys((_pubkeys: string[]) => { 49 | // Unique values only: 50 | const arr = [..._pubkeys, pubkey].filter(uniqValues).filter((_pubkey) => { 51 | return !requestedPubkeys.includes(_pubkey) 52 | }) 53 | 54 | return arr 55 | }) 56 | }, [pubkey, setQueuedPubkeys, alreadyRequested, requestedPubkeys]) 57 | 58 | return { 59 | pubkeysToFetch: isReadyToFetch ? queuedPubkeys : [], 60 | } 61 | } 62 | 63 | export function useProfile({ 64 | pubkey, 65 | enabled: _enabled = true, 66 | }: { 67 | pubkey: string 68 | enabled?: boolean 69 | }) { 70 | const [, setRequestedPubkeys] = useAtom(requestedPubkeysAtom) 71 | const { pubkeysToFetch } = useProfileQueue({ pubkey }) 72 | const enabled = _enabled && !!pubkeysToFetch.length 73 | 74 | const [fetchedProfiles, setFetchedProfiles] = useAtom(fetchedProfilesAtom) 75 | 76 | const { onEvent, onSubscribe, isLoading, onDone } = useNostrEvents({ 77 | filter: { 78 | kinds: [0], 79 | authors: pubkeysToFetch, 80 | }, 81 | enabled, 82 | }) 83 | 84 | onSubscribe(() => { 85 | // Reset list 86 | // (We've already opened a subscription to these pubkeys now) 87 | setRequestedPubkeys((_pubkeys) => { 88 | return [..._pubkeys, ...pubkeysToFetch].filter(uniqValues) 89 | }) 90 | }) 91 | 92 | onEvent((rawMetadata) => { 93 | try { 94 | const metadata: Metadata = JSON.parse(rawMetadata.content) 95 | const metaPubkey = rawMetadata.pubkey 96 | 97 | if (metadata) { 98 | setFetchedProfiles((_profiles: Record) => { 99 | return { 100 | ..._profiles, 101 | [metaPubkey]: metadata, 102 | } 103 | }) 104 | } 105 | } catch (err) { 106 | console.error(err, rawMetadata) 107 | } 108 | }) 109 | 110 | const metadata = fetchedProfiles[pubkey] 111 | const npub = nip19.npubEncode(pubkey) 112 | 113 | return { 114 | isLoading, 115 | onDone, 116 | data: metadata 117 | ? { 118 | ...metadata, 119 | npub, 120 | } 121 | : undefined, 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | export const uniqBy = (arr: T[], key: keyof T): T[] => { 2 | return Object.values( 3 | arr.reduce( 4 | (map, item) => ({ 5 | ...map, 6 | [`${item[key]}`]: item, 7 | }), 8 | {}, 9 | ), 10 | ) 11 | } 12 | 13 | export const uniqValues = (value: string, index: number, self: string[]) => { 14 | return self.indexOf(value) === index 15 | } 16 | 17 | export const dateToUnix = (_date?: Date) => { 18 | const date = _date || new Date() 19 | 20 | return Math.floor(date.getTime() / 1000) 21 | } 22 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | // see https://www.typescriptlang.org/tsconfig to better understand tsconfigs 3 | "extends": "@tsconfig/create-react-app/tsconfig.json", 4 | "include": [ 5 | "src", 6 | "types" 7 | ], 8 | "compilerOptions": { 9 | "jsx": "react-jsx" 10 | }, 11 | } 12 | --------------------------------------------------------------------------------