diff --git a/README.md b/README.md index d8311065e..8fa1df20e 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,12 @@ # TODO - Adding loading state to `useUserMetadata` so views can show loading state +- Add a debounce to user metadata services so it dose not spam the relay when updating subscription +- user metadata service: remove author from subscription once metadata is returned +- create a stats page showing state of local db and info about app +- create user timeline service that caching events and supports loading older events on request + +## Ideas + +- come up with a clever name +- build support for DMs diff --git a/package.json b/package.json index 4096cd8f8..157f4cc24 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "react-error-boundary": "^3.1.4", "react-markdown": "^8.0.4", "react-router-dom": "^6.5.0", + "react-singleton-hook": "^4.0.1", "react-use": "^17.4.0", "rxjs": "^7.8.0" }, diff --git a/src/components/page.tsx b/src/components/page.tsx index eee601521..a041346e2 100644 --- a/src/components/page.tsx +++ b/src/components/page.tsx @@ -4,13 +4,13 @@ import { useNavigate } from "react-router-dom"; import { ErrorBoundary } from "./error-boundary"; import { ConnectedRelays } from "./connected-relays"; -export const Page = ({ children }: {children: React.ReactNode}) => { +export const Page = ({ children }: { children: React.ReactNode }) => { const navigate = useNavigate(); return ( - + @@ -19,7 +19,7 @@ export const Page = ({ children }: {children: React.ReactNode}) => { {children} - + diff --git a/src/components/post.tsx b/src/components/post.tsx index d4004e20c..495bec17c 100644 --- a/src/components/post.tsx +++ b/src/components/post.tsx @@ -5,6 +5,7 @@ import { Card, CardBody, CardHeader, + Code, Flex, Heading, HStack, @@ -26,7 +27,7 @@ export type PostProps = { }; export const Post = React.memo(({ event }: PostProps) => { const { isOpen, onClose, onOpen } = useDisclosure(); - const metadata = useUserMetadata(event.pubkey); + const { metadata } = useUserMetadata(event.pubkey); const isLong = event.content.length > 800; const username = metadata @@ -34,11 +35,11 @@ export const Post = React.memo(({ event }: PostProps) => { : event.pubkey; return ( - - + + - - + + @@ -49,7 +50,7 @@ export const Post = React.memo(({ event }: PostProps) => { - + @@ -64,6 +65,7 @@ export const Post = React.memo(({ event }: PostProps) => { )} + {event.id} diff --git a/src/components/user-avatar-link.tsx b/src/components/user-avatar-link.tsx index afec2fa07..9ca0fd0de 100644 --- a/src/components/user-avatar-link.tsx +++ b/src/components/user-avatar-link.tsx @@ -1,13 +1,10 @@ import { Tooltip } from "@chakra-ui/react"; import { Link } from "react-router-dom"; import { useUserMetadata } from "../hooks/use-user-metadata"; -import { UserAvatar } from "./user-avatar"; +import { UserAvatar, UserAvatarProps } from "./user-avatar"; -export type UserAvatarProps = { - pubkey: string; -}; -export const UserAvatarLink = ({ pubkey }: UserAvatarProps) => { - const metadata = useUserMetadata(pubkey); +export const UserAvatarLink = ({ pubkey, ...props }: UserAvatarProps) => { + const { metadata } = useUserMetadata(pubkey); let label = "Loading..."; if (metadata?.display_name && metadata?.name) { @@ -21,7 +18,7 @@ export const UserAvatarLink = ({ pubkey }: UserAvatarProps) => { return ( - + ); diff --git a/src/components/user-avatar.tsx b/src/components/user-avatar.tsx index 393328dfe..9fab387c0 100644 --- a/src/components/user-avatar.tsx +++ b/src/components/user-avatar.tsx @@ -1,5 +1,5 @@ import React, { useMemo } from "react"; -import { Avatar } from "@chakra-ui/react"; +import { Avatar, AvatarProps } from "@chakra-ui/react"; import Identicon from "identicon.js"; import { useUserMetadata } from "../hooks/use-user-metadata"; @@ -11,18 +11,20 @@ function getIdenticon(pubkey: string) { return cache[pubkey]; } -export type UserAvatarProps = { +export type UserAvatarProps = Omit & { pubkey: string; }; -export const UserAvatar = React.memo(({ pubkey }: UserAvatarProps) => { - const metadata = useUserMetadata(pubkey); +export const UserAvatar = React.memo( + ({ pubkey, ...props }: UserAvatarProps) => { + const { metadata } = useUserMetadata(pubkey); - const url = useMemo(() => { - return ( - metadata?.picture ?? - `data:image/svg+xml;base64,${getIdenticon(pubkey).toString()}` - ); - }, [metadata]); + const url = useMemo(() => { + return ( + metadata?.picture ?? + `data:image/svg+xml;base64,${getIdenticon(pubkey).toString()}` + ); + }, [metadata]); - return ; -}); + return ; + } +); diff --git a/src/hooks/use-user-metadata.ts b/src/hooks/use-user-metadata.ts index f4c9c04ba..619399241 100644 --- a/src/hooks/use-user-metadata.ts +++ b/src/hooks/use-user-metadata.ts @@ -1,11 +1,16 @@ import { useMemo } from "react"; -import { useObservable } from "react-use"; import userMetadata from "../services/user-metadata"; +import useSubject from "./use-subject"; export function useUserMetadata(pubkey: string) { const observable = useMemo( () => userMetadata.requestUserMetadata(pubkey), [pubkey] ); - return useObservable(observable); + const metadata = useSubject(observable) ?? undefined; + + return { + loading: !metadata, + metadata, + }; } diff --git a/src/services/relays/relay.ts b/src/services/relays/relay.ts index 983ef04c6..613bf2a27 100644 --- a/src/services/relays/relay.ts +++ b/src/services/relays/relay.ts @@ -37,14 +37,14 @@ export class Relay { this.onOpen.next(this); if (import.meta.env.DEV) { - console.info(`Relay ${this.url} opened`); + console.info(`Relay: ${this.url} connected`); } }; this.ws.onclose = () => { this.onClose.next(this); if (import.meta.env.DEV) { - console.info(`Relay ${this.url} closed`); + console.info(`Relay: ${this.url} disconnected`); } }; this.ws.onmessage = this.handleMessage.bind(this); @@ -94,7 +94,7 @@ export class Relay { break; } } catch (e) { - console.log(`Failed to parse event from ${this.url}`); + console.log(`Relay: Failed to parse event from ${this.url}`); console.log(event.data); } } diff --git a/src/services/subscriptions.ts b/src/services/subscriptions.ts index 3e72b2bd5..45d558073 100644 --- a/src/services/subscriptions.ts +++ b/src/services/subscriptions.ts @@ -65,7 +65,7 @@ export class Subscription { } if (import.meta.env.DEV) { - console.info(`Subscription ${this.name || this.id} opened`); + console.info(`Subscription: "${this.name || this.id}" opened`); } } close() { @@ -80,7 +80,7 @@ export class Subscription { } if (import.meta.env.DEV) { - console.info(`Subscription ${this.name || this.id} closed`); + console.info(`Subscription: "${this.name || this.id}" closed`); } } } diff --git a/src/services/user-metadata.ts b/src/services/user-metadata.ts index 8ba558458..5a3ad429e 100644 --- a/src/services/user-metadata.ts +++ b/src/services/user-metadata.ts @@ -4,6 +4,15 @@ import db from "./db"; import settings from "./settings"; import { Subscription } from "./subscriptions"; +function debounce(func: T, timeout = 300) { + let timer: number | undefined; + return (...args: any[]) => { + clearTimeout(timer); + // @ts-ignore + timer = setTimeout(() => func(args), timeout); + }; +} + class UserMetadataService { requests = new Set(); subjects = new Map>(); @@ -23,6 +32,10 @@ class UserMetadataService { const metadata = JSON.parse(event.content); this.getUserSubject(event.pubkey).next(metadata); + + // remove the pubkey from requests since is have the data + this.requests.delete(event.pubkey); + this.update(); } catch (e) {} }); @@ -49,25 +62,27 @@ class UserMetadataService { const request = () => { if (!this.requests.has(pubkey)) { this.requests.add(pubkey); - this.updateSubscription(); + this.update(); } }; - if (useCache && !subject.getValue()) { - db.get("user-metadata", pubkey).then((cachedEvent) => { - if (cachedEvent) { - try { - subject.next(JSON.parse(cachedEvent.content)); - } catch (e) { - request(); - } - } else request(); - }); + if (useCache) { + if (!subject.getValue()) { + db.get("user-metadata", pubkey).then((cachedEvent) => { + if (cachedEvent) { + try { + subject.next(JSON.parse(cachedEvent.content)); + } catch (e) { + request(); + } + } else request(); + }); + } } else request(); return subject; } - updateSubscription() { + private updateSubscription() { const pubkeys = Array.from(this.requests.keys()); if (pubkeys.length === 0) { @@ -79,6 +94,7 @@ class UserMetadataService { } } } + update = debounce(this.updateSubscription.bind(this), 500); pruneRequests() { let removed = false; @@ -91,7 +107,7 @@ class UserMetadataService { } } - if (removed) this.updateSubscription(); + if (removed) this.update(); } } diff --git a/src/views/global/index.tsx b/src/views/global/index.tsx index d48d93c6e..da95af1a4 100644 --- a/src/views/global/index.tsx +++ b/src/views/global/index.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useState } from "react"; -import { SkeletonText } from "@chakra-ui/react"; +import { Flex, SkeletonText } from "@chakra-ui/react"; import { useSubscription } from "../../hooks/use-subscription"; import { Post } from "../../components/post"; import moment from "moment/moment"; @@ -41,10 +41,10 @@ export const GlobalView = () => { if (timeline.length > 20) timeline.length = 20; return ( - <> + {timeline.map((event) => ( ))} - + ); }; diff --git a/src/views/home.tsx b/src/views/home.tsx index 85b838b8d..8f70058e6 100644 --- a/src/views/home.tsx +++ b/src/views/home.tsx @@ -18,6 +18,7 @@ export const HomeView = () => { + diff --git a/src/views/user/index.tsx b/src/views/user/index.tsx index 833e9a95c..9276706ef 100644 --- a/src/views/user/index.tsx +++ b/src/views/user/index.tsx @@ -1,19 +1,18 @@ import { - Box, + Flex, Heading, - HStack, SkeletonText, Tab, TabList, TabPanel, TabPanels, Tabs, + Text, VStack, } from "@chakra-ui/react"; import { useParams } from "react-router-dom"; import { UserPostsTab } from "./posts"; import { useUserMetadata } from "../../hooks/use-user-metadata"; -import ReactMarkdown from "react-markdown"; import { UserAvatar } from "../../components/user-avatar"; import { getUserFullName } from "../../helpers/user-metadata"; @@ -24,24 +23,19 @@ export const UserView = () => { throw new Error("No pubkey"); } - const metadata = useUserMetadata(pubkey); + const { metadata, loading: loadingMetadata } = useUserMetadata(pubkey); const label = metadata ? getUserFullName(metadata) || pubkey : pubkey; return ( {" "} - - - + + + {label} - {/* {metadata?.name} */} - - - {metadata?.about ? ( - {metadata.about} - ) : ( - - )} + {loadingMetadata ? : {metadata?.about}} + + Posts diff --git a/src/views/user/posts.tsx b/src/views/user/posts.tsx index 8a2616b4a..4a1d11283 100644 --- a/src/views/user/posts.tsx +++ b/src/views/user/posts.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useState } from "react"; -import { SkeletonText } from "@chakra-ui/react"; +import { Flex, SkeletonText } from "@chakra-ui/react"; import { useSubscription } from "../../hooks/use-subscription"; import { Post } from "../../components/post"; import { NostrEvent } from "../../types/nostr-event"; @@ -43,10 +43,10 @@ export const UserPostsTab = ({ pubkey }: { pubkey: string }) => { if (timeline.length > 20) timeline.length = 20; return ( - <> + {timeline.map((event) => ( ))} - + ); }; diff --git a/yarn.lock b/yarn.lock index e0f81f581..110a4a4b5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4756,6 +4756,11 @@ react-router@6.5.0: dependencies: "@remix-run/router" "1.1.0" +react-singleton-hook@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/react-singleton-hook/-/react-singleton-hook-4.0.1.tgz#98082b37be06b22845ced85eb11e02bacc3b2580" + integrity sha512-fWuk8VxcZPChrkQasDLM8pgd/7kyi+Cr/5FfCiD99FicjEru+JmtEZNnN4lJ8Z7KbqAST5CYPlpz6lmNsZFGNw== + react-style-singleton@^2.2.1: version "2.2.1" resolved "https://registry.yarnpkg.com/react-style-singleton/-/react-style-singleton-2.2.1.tgz#f99e420492b2d8f34d38308ff660b60d0b1205b4"