From f70d752fae9008d734efb81f04bcefc84c922548 Mon Sep 17 00:00:00 2001 From: Martti Malmi Date: Tue, 26 Dec 2023 20:15:26 +0200 Subject: [PATCH] useCachedFetch hook for trending api calls --- .../Event/Create/TrendingHashTagsLine.tsx | 17 +-- .../app/src/Element/SuggestedProfiles.tsx | 65 ++++++----- .../src/Element/Trending/TrendingHashtags.tsx | 38 +++---- .../src/Element/Trending/TrendingPosts.tsx | 81 +++++++------- .../src/Element/Trending/TrendingUsers.tsx | 37 +++---- packages/app/src/External/NostrBand.ts | 103 ++---------------- packages/app/src/External/SemisolDev.ts | 44 +------- packages/app/src/Hooks/useCachedFetch.ts | 46 ++++++++ packages/app/src/index.css | 1 - 9 files changed, 167 insertions(+), 265 deletions(-) create mode 100644 packages/app/src/Hooks/useCachedFetch.ts diff --git a/packages/app/src/Element/Event/Create/TrendingHashTagsLine.tsx b/packages/app/src/Element/Event/Create/TrendingHashTagsLine.tsx index 459830903..c447f27c7 100644 --- a/packages/app/src/Element/Event/Create/TrendingHashTagsLine.tsx +++ b/packages/app/src/Element/Event/Create/TrendingHashTagsLine.tsx @@ -1,23 +1,18 @@ -import { useEffect, useState } from "react"; import { useLocale } from "@/IntlProvider"; import NostrBandApi from "@/External/NostrBand"; import { FormattedMessage } from "react-intl"; +import useCachedFetch from "@/Hooks/useCachedFetch"; export function TrendingHashTagsLine(props: { onClick: (tag: string) => void }) { - const [hashtags, setHashtags] = useState>(); const { lang } = useLocale(); + const api = new NostrBandApi(); + const trendingHashtagsUrl = api.trendingHashtagsUrl(lang); + const storageKey = `nostr-band-${trendingHashtagsUrl}`; - async function loadTrendingHashtags() { - const api = new NostrBandApi(); - const rsp = await api.trendingHashtags(lang); - setHashtags(rsp.hashtags); - } + const { data: hashtags, isLoading, error } = useCachedFetch(trendingHashtagsUrl, storageKey, data => data.hashtags); - useEffect(() => { - loadTrendingHashtags().catch(console.error); - }, []); + if (isLoading || error || !hashtags || hashtags.length === 0) return null; - if (!hashtags || hashtags.length === 0) return; return (
diff --git a/packages/app/src/Element/SuggestedProfiles.tsx b/packages/app/src/Element/SuggestedProfiles.tsx index 72d057be3..77aa0541f 100644 --- a/packages/app/src/Element/SuggestedProfiles.tsx +++ b/packages/app/src/Element/SuggestedProfiles.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useState } from "react"; import { HexKey, NostrPrefix } from "@snort/system"; import { FormattedMessage } from "react-intl"; @@ -9,6 +9,7 @@ import SemisolDevApi from "@/External/SemisolDev"; import useLogin from "@/Hooks/useLogin"; import { hexToBech32 } from "@/SnortUtils"; import { ErrorOrOffline } from "./ErrorOrOffline"; +import useCachedFetch from "@/Hooks/useCachedFetch"; enum Provider { NostrBand = 1, @@ -17,42 +18,40 @@ enum Provider { export default function SuggestedProfiles() { const login = useLogin(s => ({ publicKey: s.publicKey, follows: s.follows.item })); - const [userList, setUserList] = useState(); const [provider, setProvider] = useState(Provider.NostrBand); - const [error, setError] = useState(); - async function loadSuggestedProfiles() { - if (!login.publicKey) return; - setUserList(undefined); - setError(undefined); - - try { - switch (provider) { - case Provider.NostrBand: { - const api = new NostrBandApi(); - const users = await api.sugguestedFollows(hexToBech32(NostrPrefix.PublicKey, login.publicKey)); - const keys = users.profiles.map(a => a.pubkey); - setUserList(keys); - break; - } - case Provider.SemisolDev: { - const api = new SemisolDevApi(); - const users = await api.sugguestedFollows(login.publicKey, login.follows); - const keys = users.recommendations.sort(a => a[1]).map(a => a[0]); - setUserList(keys); - break; - } + const getUrlAndKey = () => { + if (!login.publicKey) return { url: null, key: null }; + switch (provider) { + case Provider.NostrBand: { + const api = new NostrBandApi(); + const url = api.suggestedFollowsUrl(hexToBech32(NostrPrefix.PublicKey, login.publicKey)); + return { url, key: `nostr-band-${url}` }; } - } catch (e) { - if (e instanceof Error) { - setError(e); + case Provider.SemisolDev: { + const api = new SemisolDevApi(); + const url = api.suggestedFollowsUrl(login.publicKey, login.follows); + return { url, key: `semisol-dev-${url}` }; } + default: + return { url: null, key: null }; } - } + }; - useEffect(() => { - loadSuggestedProfiles(); - }, [login.publicKey, login.follows, provider]); + const { url, key } = getUrlAndKey(); + const { data: userList, error } = useCachedFetch(url, key, data => { + switch (provider) { + case Provider.NostrBand: + return data.profiles.map(a => a.pubkey); + case Provider.SemisolDev: + return data.recommendations.sort(a => a[1]).map(a => a[0]); + default: + return []; + } + }); + + if (error) return {}} />; + if (!userList) return ; return ( <> @@ -63,9 +62,7 @@ export default function SuggestedProfiles() { {/**/}
- {error && } - {userList && } - {!userList && !error && } + ); } diff --git a/packages/app/src/Element/Trending/TrendingHashtags.tsx b/packages/app/src/Element/Trending/TrendingHashtags.tsx index f72abf365..8cf21b086 100644 --- a/packages/app/src/Element/Trending/TrendingHashtags.tsx +++ b/packages/app/src/Element/Trending/TrendingHashtags.tsx @@ -1,12 +1,12 @@ -import { ReactNode, useEffect, useState } from "react"; - -import PageSpinner from "@/Element/PageSpinner"; +import { ReactNode } from "react"; import NostrBandApi from "@/External/NostrBand"; import { ErrorOrOffline } from "../ErrorOrOffline"; import { HashTagHeader } from "@/Pages/HashTagsPage"; import { useLocale } from "@/IntlProvider"; import classNames from "classnames"; import { Link } from "react-router-dom"; +import useCachedFetch from "@/Hooks/useCachedFetch"; +import PageSpinner from "@/Element/PageSpinner"; export default function TrendingHashtags({ title, @@ -17,38 +17,28 @@ export default function TrendingHashtags({ count?: number; short?: boolean; }) { - const [hashtags, setHashtags] = useState>(); - const [error, setError] = useState(); const { lang } = useLocale(); + const api = new NostrBandApi(); + const trendingHashtagsUrl = api.trendingHashtagsUrl(lang); + const storageKey = `nostr-band-${trendingHashtagsUrl}`; - async function loadTrendingHashtags() { - const api = new NostrBandApi(); - const rsp = await api.trendingHashtags(lang); - setHashtags(rsp.hashtags.slice(0, count)); // Limit the number of hashtags to the count - } + const { + data: hashtags, + error, + isLoading, + } = useCachedFetch(trendingHashtagsUrl, storageKey, data => data.hashtags.slice(0, count)); - useEffect(() => { - loadTrendingHashtags().catch(e => { - if (e instanceof Error) { - setError(e); - } - }); - }, []); - - if (error) return ; - if (!hashtags) return ; + if (error) return {}} className="p" />; + if (isLoading) return ; return ( <> {title} {hashtags.map(a => { if (short) { - // return just the hashtag (not HashTagHeader) and post count return (
- - #{a.hashtag} - + #{a.hashtag}
); } else { diff --git a/packages/app/src/Element/Trending/TrendingPosts.tsx b/packages/app/src/Element/Trending/TrendingPosts.tsx index cffeac149..e40e8fa57 100644 --- a/packages/app/src/Element/Trending/TrendingPosts.tsx +++ b/packages/app/src/Element/Trending/TrendingPosts.tsx @@ -1,5 +1,5 @@ -import { useEffect, useState } from "react"; -import { NostrEvent, NostrLink, TaggedNostrEvent } from "@snort/system"; +import { useState } from "react"; +import { NostrLink, TaggedNostrEvent } from "@snort/system"; import { useReactions } from "@snort/system-react"; import PageSpinner from "@/Element/PageSpinner"; @@ -14,54 +14,42 @@ import { DisplayAs, DisplayAsSelector } from "@/Element/Feed/DisplayAsSelector"; import ImageGridItem from "@/Element/Feed/ImageGridItem"; import { SpotlightThreadModal } from "@/Element/Spotlight/SpotlightThreadModal"; import useLogin from "@/Hooks/useLogin"; +import useCachedFetch from "@/Hooks/useCachedFetch"; export default function TrendingNotes({ count = Infinity, small = false }) { + const api = new NostrBandApi(); + const { lang } = useLocale(); + const trendingNotesUrl = api.trendingNotesUrl(lang); // Get the URL for trending notes + const storageKey = `nostr-band-${trendingNotesUrl}`; + + const { + data: trendingNotesData, + isLoading, + error, + } = useCachedFetch( + trendingNotesUrl, + storageKey, + data => data.notes.map(a => a.event), // Process the data as needed + ); + const login = useLogin(); const displayAsInitial = small ? "list" : login.feedDisplayAs ?? "list"; - // Added count prop with a default value - const [posts, setPosts] = useState>(); - const [error, setError] = useState(); - const { lang } = useLocale(); - const { isEventMuted } = useModeration(); const [displayAs, setDisplayAs] = useState(displayAsInitial); - const related = useReactions("trending", posts?.map(a => NostrLink.fromEvent(a)) ?? [], undefined, true); + const { isEventMuted } = useModeration(); + const related = useReactions("trending", trendingNotesData?.map(a => NostrLink.fromEvent(a)) ?? [], undefined, true); const [modalThread, setModalThread] = useState(undefined); - async function loadTrendingNotes() { - const api = new NostrBandApi(); - const trending = await api.trendingNotes(lang); - setPosts(trending.notes.map(a => a.event)); - } + if (error) return ; + if (isLoading) return ; - useEffect(() => { - loadTrendingNotes().catch(e => { - if (e instanceof Error) { - setError(e); - } - }); - }, []); - - if (error) return ; - if (!posts) return ; - - // if small, render less stuff - const options = { - showFooter: !small, - showReactionsLink: !small, - showMedia: !small, - longFormPreview: !small, - truncate: small, - showContextMenu: !small, - }; - - const filteredAndLimitedPosts = () => { - return posts.filter(a => !isEventMuted(a)).slice(0, count); - }; + const filteredAndLimitedPosts = trendingNotesData + ? trendingNotesData.filter(a => !isEventMuted(a)).slice(0, count) + : []; const renderGrid = () => { return (
- {filteredAndLimitedPosts().map(e => ( + {filteredAndLimitedPosts.map(e => ( { - return filteredAndLimitedPosts().map(e => + return filteredAndLimitedPosts.map(e => small ? ( ) : ( - + ), ); }; diff --git a/packages/app/src/Element/Trending/TrendingUsers.tsx b/packages/app/src/Element/Trending/TrendingUsers.tsx index 2257bea2d..1467bf5bc 100644 --- a/packages/app/src/Element/Trending/TrendingUsers.tsx +++ b/packages/app/src/Element/Trending/TrendingUsers.tsx @@ -1,32 +1,29 @@ -import { ReactNode, useEffect, useState } from "react"; +import { ReactNode } from "react"; import { HexKey } from "@snort/system"; - import FollowListBase from "@/Element/User/FollowListBase"; import PageSpinner from "@/Element/PageSpinner"; import NostrBandApi from "@/External/NostrBand"; import { ErrorOrOffline } from "../ErrorOrOffline"; +import useCachedFetch from "@/Hooks/useCachedFetch"; export default function TrendingUsers({ title, count = Infinity }: { title?: ReactNode; count?: number }) { - const [userList, setUserList] = useState(); - const [error, setError] = useState(); + const api = new NostrBandApi(); + const trendingProfilesUrl = api.trendingProfilesUrl(); + const storageKey = `nostr-band-${trendingProfilesUrl}`; - async function loadTrendingUsers() { - const api = new NostrBandApi(); - const users = await api.trendingProfiles(); - const keys = users.profiles.map(a => a.pubkey).slice(0, count); // Limit the user list to the count - setUserList(keys); + const { + data: trendingUsersData, + isLoading, + error, + } = useCachedFetch(trendingProfilesUrl, storageKey, data => data.profiles.map(a => a.pubkey)); + + if (error) { + return {}} className="p" />; } - useEffect(() => { - loadTrendingUsers().catch(e => { - if (e instanceof Error) { - setError(e); - } - }); - }, []); + if (isLoading || !trendingUsersData) { + return ; + } - if (error) return ; - if (!userList) return ; - - return ; + return ; } diff --git a/packages/app/src/External/NostrBand.ts b/packages/app/src/External/NostrBand.ts index 0f819126b..d6e331ceb 100644 --- a/packages/app/src/External/NostrBand.ts +++ b/packages/app/src/External/NostrBand.ts @@ -1,105 +1,20 @@ -import { throwIfOffline } from "@snort/shared"; -import { NostrEvent } from "@snort/system"; - -export interface TrendingUser { - pubkey: string; -} - -export interface TrendingUserResponse { - profiles: Array; -} - -export interface TrendingNote { - event: NostrEvent; - author: NostrEvent; // kind0 event -} - -export interface TrendingNoteResponse { - notes: Array; -} - -export interface TrendingHashtagsResponse { - hashtags: Array<{ - hashtag: string; - posts: number; - }>; -} - -export interface SuggestedFollow { - pubkey: string; -} - -export interface SuggestedFollowsResponse { - profiles: Array; -} - -export class NostrBandError extends Error { - body: string; - statusCode: number; - - constructor(message: string, body: string, status: number) { - super(message); - this.body = body; - this.statusCode = status; - } -} - export default class NostrBandApi { readonly #url = "https://api.nostr.band"; readonly #supportedLangs = ["en", "de", "ja", "zh", "th", "pt", "es", "fr"]; - async trendingProfiles() { - return await this.#json("GET", "/v0/trending/profiles"); + + trendingProfilesUrl() { + return `${this.#url}/v0/trending/profiles`; } - async trendingNotes(lang?: string) { - if (lang && this.#supportedLangs.includes(lang)) { - return await this.#json("GET", `/v0/trending/notes?lang=${lang}`); - } - return await this.#json("GET", "/v0/trending/notes"); + trendingNotesUrl(lang?: string) { + return `${this.#url}/v0/trending/notes${lang && this.#supportedLangs.includes(lang) ? `?lang=${lang}` : ""}`; } - async sugguestedFollows(pubkey: string) { - return await this.#json("GET", `/v0/suggested/profiles/${pubkey}`); + suggestedFollowsUrl(pubkey: string) { + return `${this.#url}/v0/suggested/profiles/${pubkey}`; } - async trendingHashtags(lang?: string) { - if (lang && this.#supportedLangs.includes(lang)) { - return await this.#json("GET", `/v0/trending/hashtags?lang=${lang}`); - } - return await this.#json("GET", "/v0/trending/hashtags"); - } - - async #json(method: string, path: string) { - throwIfOffline(); - - const storageKey = `nostr-band-${path}`; - const cachedData = localStorage.getItem(storageKey); - if (cachedData) { - const parsedData = JSON.parse(cachedData); - const { timestamp, data } = parsedData; - - const ageInMinutes = (new Date().getTime() - timestamp) / 1000 / 60; - if (ageInMinutes < 15) { - return data as T; - } - } - - try { - const res = await fetch(`${this.#url}${path}`, { method: method ?? "GET" }); - if (res.ok) { - const data = (await res.json()) as T; - // Cache the new data with a timestamp - localStorage.setItem(storageKey, JSON.stringify({ data, timestamp: new Date().getTime() })); - return data; - } else { - throw new NostrBandError("Failed to load content from nostr.band", await res.text(), res.status); - } - } catch (error) { - if (cachedData) { - return JSON.parse(cachedData).data as T; - } else { - throw error; - } - } + trendingHashtagsUrl(lang?: string) { + return `${this.#url}/v0/trending/hashtags${lang && this.#supportedLangs.includes(lang) ? `?lang=${lang}` : ""}`; } } diff --git a/packages/app/src/External/SemisolDev.ts b/packages/app/src/External/SemisolDev.ts index 5fbcd1116..859e787c5 100644 --- a/packages/app/src/External/SemisolDev.ts +++ b/packages/app/src/External/SemisolDev.ts @@ -1,46 +1,8 @@ -import { throwIfOffline } from "@snort/shared"; - -export interface RecommendedProfilesResponse { - quality: number; - recommendations: Array<[pubkey: string, score: number]>; -} - -export class SemisolDevApiError extends Error { - body: string; - statusCode: number; - - constructor(message: string, body: string, status: number) { - super(message); - this.body = body; - this.statusCode = status; - } -} - export default class SemisolDevApi { readonly #url = "https://api.semisol.dev"; - async sugguestedFollows(pubkey: string, follows: Array) { - return await this.#json("POST", "/nosgraph/v1/recommend", { - pubkey, - exclude: [], - following: follows, - }); - } - - async #json(method: string, path: string, body?: unknown) { - throwIfOffline(); - const url = `${this.#url}${path}`; - const res = await fetch(url, { - method: method ?? "GET", - body: body ? JSON.stringify(body) : undefined, - headers: { - ...(body ? { "content-type": "application/json" } : {}), - }, - }); - if (res.ok) { - return (await res.json()) as T; - } else { - throw new SemisolDevApiError(`Failed to load content from ${url}`, await res.text(), res.status); - } + suggestedFollowsUrl(pubkey: string, follows: Array) { + const query = new URLSearchParams({ pubkey, follows: JSON.stringify(follows) }); + return `${this.#url}/nosgraph/v1/recommend?${query.toString()}`; } } diff --git a/packages/app/src/Hooks/useCachedFetch.ts b/packages/app/src/Hooks/useCachedFetch.ts new file mode 100644 index 000000000..e21cd20c6 --- /dev/null +++ b/packages/app/src/Hooks/useCachedFetch.ts @@ -0,0 +1,46 @@ +import { useEffect, useMemo, useState } from "react"; + +const useCachedFetch = (url, storageKey, dataProcessor = data => data) => { + const cachedData = useMemo(() => { + const cached = localStorage.getItem(storageKey); + return cached ? JSON.parse(cached) : null; + }, [storageKey]); + + const initialData = cachedData ? cachedData.data : null; + const [data, setData] = useState(initialData); + const [isLoading, setIsLoading] = useState(!cachedData); + const [error, setError] = useState(null); + + useEffect(() => { + const fetchData = async () => { + setIsLoading(true); + setError(null); + + try { + const res = await fetch(url); + if (!res.ok) { + throw new Error(`HTTP error! status: ${res.status}`); + } + const fetchedData = await res.json(); + const processedData = dataProcessor(fetchedData); + setData(processedData); + localStorage.setItem(storageKey, JSON.stringify({ data: processedData, timestamp: new Date().getTime() })); + } catch (e) { + setError(e); + if (cachedData?.data) { + setData(cachedData.data); + } + } finally { + setIsLoading(false); + } + }; + + if (!cachedData || (new Date().getTime() - cachedData.timestamp) / 1000 / 60 >= 15) { + fetchData(); + } + }, [url, storageKey, dataProcessor]); + + return { data, isLoading, error }; +}; + +export default useCachedFetch; diff --git a/packages/app/src/index.css b/packages/app/src/index.css index d93808973..afe0cd46e 100644 --- a/packages/app/src/index.css +++ b/packages/app/src/index.css @@ -75,7 +75,6 @@ } html { - scroll-behavior: smooth; -webkit-tap-highlight-color: transparent; }