forked from Kieran/snort
useCachedFetch hook for trending api calls
This commit is contained in:
parent
6b88df96ab
commit
f70d752fae
@ -1,23 +1,18 @@
|
|||||||
import { useEffect, useState } from "react";
|
|
||||||
import { useLocale } from "@/IntlProvider";
|
import { useLocale } from "@/IntlProvider";
|
||||||
import NostrBandApi from "@/External/NostrBand";
|
import NostrBandApi from "@/External/NostrBand";
|
||||||
import { FormattedMessage } from "react-intl";
|
import { FormattedMessage } from "react-intl";
|
||||||
|
import useCachedFetch from "@/Hooks/useCachedFetch";
|
||||||
|
|
||||||
export function TrendingHashTagsLine(props: { onClick: (tag: string) => void }) {
|
export function TrendingHashTagsLine(props: { onClick: (tag: string) => void }) {
|
||||||
const [hashtags, setHashtags] = useState<Array<{ hashtag: string; posts: number }>>();
|
|
||||||
const { lang } = useLocale();
|
const { lang } = useLocale();
|
||||||
|
|
||||||
async function loadTrendingHashtags() {
|
|
||||||
const api = new NostrBandApi();
|
const api = new NostrBandApi();
|
||||||
const rsp = await api.trendingHashtags(lang);
|
const trendingHashtagsUrl = api.trendingHashtagsUrl(lang);
|
||||||
setHashtags(rsp.hashtags);
|
const storageKey = `nostr-band-${trendingHashtagsUrl}`;
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
const { data: hashtags, isLoading, error } = useCachedFetch(trendingHashtagsUrl, storageKey, data => data.hashtags);
|
||||||
loadTrendingHashtags().catch(console.error);
|
|
||||||
}, []);
|
if (isLoading || error || !hashtags || hashtags.length === 0) return null;
|
||||||
|
|
||||||
if (!hashtags || hashtags.length === 0) return;
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col g4">
|
<div className="flex flex-col g4">
|
||||||
<small>
|
<small>
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useState } from "react";
|
||||||
import { HexKey, NostrPrefix } from "@snort/system";
|
import { HexKey, NostrPrefix } from "@snort/system";
|
||||||
import { FormattedMessage } from "react-intl";
|
import { FormattedMessage } from "react-intl";
|
||||||
|
|
||||||
@ -9,6 +9,7 @@ import SemisolDevApi from "@/External/SemisolDev";
|
|||||||
import useLogin from "@/Hooks/useLogin";
|
import useLogin from "@/Hooks/useLogin";
|
||||||
import { hexToBech32 } from "@/SnortUtils";
|
import { hexToBech32 } from "@/SnortUtils";
|
||||||
import { ErrorOrOffline } from "./ErrorOrOffline";
|
import { ErrorOrOffline } from "./ErrorOrOffline";
|
||||||
|
import useCachedFetch from "@/Hooks/useCachedFetch";
|
||||||
|
|
||||||
enum Provider {
|
enum Provider {
|
||||||
NostrBand = 1,
|
NostrBand = 1,
|
||||||
@ -17,42 +18,40 @@ enum Provider {
|
|||||||
|
|
||||||
export default function SuggestedProfiles() {
|
export default function SuggestedProfiles() {
|
||||||
const login = useLogin(s => ({ publicKey: s.publicKey, follows: s.follows.item }));
|
const login = useLogin(s => ({ publicKey: s.publicKey, follows: s.follows.item }));
|
||||||
const [userList, setUserList] = useState<HexKey[]>();
|
|
||||||
const [provider, setProvider] = useState(Provider.NostrBand);
|
const [provider, setProvider] = useState(Provider.NostrBand);
|
||||||
const [error, setError] = useState<Error>();
|
|
||||||
|
|
||||||
async function loadSuggestedProfiles() {
|
const getUrlAndKey = () => {
|
||||||
if (!login.publicKey) return;
|
if (!login.publicKey) return { url: null, key: null };
|
||||||
setUserList(undefined);
|
|
||||||
setError(undefined);
|
|
||||||
|
|
||||||
try {
|
|
||||||
switch (provider) {
|
switch (provider) {
|
||||||
case Provider.NostrBand: {
|
case Provider.NostrBand: {
|
||||||
const api = new NostrBandApi();
|
const api = new NostrBandApi();
|
||||||
const users = await api.sugguestedFollows(hexToBech32(NostrPrefix.PublicKey, login.publicKey));
|
const url = api.suggestedFollowsUrl(hexToBech32(NostrPrefix.PublicKey, login.publicKey));
|
||||||
const keys = users.profiles.map(a => a.pubkey);
|
return { url, key: `nostr-band-${url}` };
|
||||||
setUserList(keys);
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
case Provider.SemisolDev: {
|
case Provider.SemisolDev: {
|
||||||
const api = new SemisolDevApi();
|
const api = new SemisolDevApi();
|
||||||
const users = await api.sugguestedFollows(login.publicKey, login.follows);
|
const url = api.suggestedFollowsUrl(login.publicKey, login.follows);
|
||||||
const keys = users.recommendations.sort(a => a[1]).map(a => a[0]);
|
return { url, key: `semisol-dev-${url}` };
|
||||||
setUserList(keys);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
if (e instanceof Error) {
|
|
||||||
setError(e);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
default:
|
||||||
|
return { url: null, key: null };
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
const { url, key } = getUrlAndKey();
|
||||||
loadSuggestedProfiles();
|
const { data: userList, error } = useCachedFetch(url, key, data => {
|
||||||
}, [login.publicKey, login.follows, provider]);
|
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 <ErrorOrOffline error={error} onRetry={() => {}} />;
|
||||||
|
if (!userList) return <PageSpinner />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@ -63,9 +62,7 @@ export default function SuggestedProfiles() {
|
|||||||
{/*<option value={Provider.SemisolDev}>semisol.dev</option>*/}
|
{/*<option value={Provider.SemisolDev}>semisol.dev</option>*/}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
{error && <ErrorOrOffline error={error} onRetry={loadSuggestedProfiles} />}
|
<FollowListBase pubkeys={userList as HexKey[]} showAbout={true} />
|
||||||
{userList && <FollowListBase pubkeys={userList} showAbout={true} />}
|
|
||||||
{!userList && !error && <PageSpinner />}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,12 +1,12 @@
|
|||||||
import { ReactNode, useEffect, useState } from "react";
|
import { ReactNode } from "react";
|
||||||
|
|
||||||
import PageSpinner from "@/Element/PageSpinner";
|
|
||||||
import NostrBandApi from "@/External/NostrBand";
|
import NostrBandApi from "@/External/NostrBand";
|
||||||
import { ErrorOrOffline } from "../ErrorOrOffline";
|
import { ErrorOrOffline } from "../ErrorOrOffline";
|
||||||
import { HashTagHeader } from "@/Pages/HashTagsPage";
|
import { HashTagHeader } from "@/Pages/HashTagsPage";
|
||||||
import { useLocale } from "@/IntlProvider";
|
import { useLocale } from "@/IntlProvider";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
|
import useCachedFetch from "@/Hooks/useCachedFetch";
|
||||||
|
import PageSpinner from "@/Element/PageSpinner";
|
||||||
|
|
||||||
export default function TrendingHashtags({
|
export default function TrendingHashtags({
|
||||||
title,
|
title,
|
||||||
@ -17,38 +17,28 @@ export default function TrendingHashtags({
|
|||||||
count?: number;
|
count?: number;
|
||||||
short?: boolean;
|
short?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const [hashtags, setHashtags] = useState<Array<{ hashtag: string; posts: number }>>();
|
|
||||||
const [error, setError] = useState<Error>();
|
|
||||||
const { lang } = useLocale();
|
const { lang } = useLocale();
|
||||||
|
|
||||||
async function loadTrendingHashtags() {
|
|
||||||
const api = new NostrBandApi();
|
const api = new NostrBandApi();
|
||||||
const rsp = await api.trendingHashtags(lang);
|
const trendingHashtagsUrl = api.trendingHashtagsUrl(lang);
|
||||||
setHashtags(rsp.hashtags.slice(0, count)); // Limit the number of hashtags to the count
|
const storageKey = `nostr-band-${trendingHashtagsUrl}`;
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
const {
|
||||||
loadTrendingHashtags().catch(e => {
|
data: hashtags,
|
||||||
if (e instanceof Error) {
|
error,
|
||||||
setError(e);
|
isLoading,
|
||||||
}
|
} = useCachedFetch(trendingHashtagsUrl, storageKey, data => data.hashtags.slice(0, count));
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (error) return <ErrorOrOffline error={error} onRetry={loadTrendingHashtags} className="p" />;
|
if (error) return <ErrorOrOffline error={error} onRetry={() => {}} className="p" />;
|
||||||
if (!hashtags) return <PageSpinner />;
|
if (isLoading) return <PageSpinner />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{title}
|
{title}
|
||||||
{hashtags.map(a => {
|
{hashtags.map(a => {
|
||||||
if (short) {
|
if (short) {
|
||||||
// return just the hashtag (not HashTagHeader) and post count
|
|
||||||
return (
|
return (
|
||||||
<div className="my-1 font-bold" key={a.hashtag}>
|
<div className="my-1 font-bold" key={a.hashtag}>
|
||||||
<Link to={`/t/${a.hashtag}`} key={a.hashtag}>
|
<Link to={`/t/${a.hashtag}`}>#{a.hashtag}</Link>
|
||||||
#{a.hashtag}
|
|
||||||
</Link>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useState } from "react";
|
||||||
import { NostrEvent, NostrLink, TaggedNostrEvent } from "@snort/system";
|
import { NostrLink, TaggedNostrEvent } from "@snort/system";
|
||||||
import { useReactions } from "@snort/system-react";
|
import { useReactions } from "@snort/system-react";
|
||||||
|
|
||||||
import PageSpinner from "@/Element/PageSpinner";
|
import PageSpinner from "@/Element/PageSpinner";
|
||||||
@ -14,54 +14,42 @@ import { DisplayAs, DisplayAsSelector } from "@/Element/Feed/DisplayAsSelector";
|
|||||||
import ImageGridItem from "@/Element/Feed/ImageGridItem";
|
import ImageGridItem from "@/Element/Feed/ImageGridItem";
|
||||||
import { SpotlightThreadModal } from "@/Element/Spotlight/SpotlightThreadModal";
|
import { SpotlightThreadModal } from "@/Element/Spotlight/SpotlightThreadModal";
|
||||||
import useLogin from "@/Hooks/useLogin";
|
import useLogin from "@/Hooks/useLogin";
|
||||||
|
import useCachedFetch from "@/Hooks/useCachedFetch";
|
||||||
|
|
||||||
export default function TrendingNotes({ count = Infinity, small = false }) {
|
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 login = useLogin();
|
||||||
const displayAsInitial = small ? "list" : login.feedDisplayAs ?? "list";
|
const displayAsInitial = small ? "list" : login.feedDisplayAs ?? "list";
|
||||||
// Added count prop with a default value
|
|
||||||
const [posts, setPosts] = useState<Array<NostrEvent>>();
|
|
||||||
const [error, setError] = useState<Error>();
|
|
||||||
const { lang } = useLocale();
|
|
||||||
const { isEventMuted } = useModeration();
|
|
||||||
const [displayAs, setDisplayAs] = useState<DisplayAs>(displayAsInitial);
|
const [displayAs, setDisplayAs] = useState<DisplayAs>(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<NostrLink | undefined>(undefined);
|
const [modalThread, setModalThread] = useState<NostrLink | undefined>(undefined);
|
||||||
|
|
||||||
async function loadTrendingNotes() {
|
if (error) return <ErrorOrOffline error={error} className="p" />;
|
||||||
const api = new NostrBandApi();
|
if (isLoading) return <PageSpinner />;
|
||||||
const trending = await api.trendingNotes(lang);
|
|
||||||
setPosts(trending.notes.map(a => a.event));
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
const filteredAndLimitedPosts = trendingNotesData
|
||||||
loadTrendingNotes().catch(e => {
|
? trendingNotesData.filter(a => !isEventMuted(a)).slice(0, count)
|
||||||
if (e instanceof Error) {
|
: [];
|
||||||
setError(e);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (error) return <ErrorOrOffline error={error} onRetry={loadTrendingNotes} className="p" />;
|
|
||||||
if (!posts) return <PageSpinner />;
|
|
||||||
|
|
||||||
// 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 renderGrid = () => {
|
const renderGrid = () => {
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-3 gap-px md:gap-1">
|
<div className="grid grid-cols-3 gap-px md:gap-1">
|
||||||
{filteredAndLimitedPosts().map(e => (
|
{filteredAndLimitedPosts.map(e => (
|
||||||
<ImageGridItem
|
<ImageGridItem
|
||||||
key={e.id}
|
key={e.id}
|
||||||
event={e as TaggedNostrEvent}
|
event={e as TaggedNostrEvent}
|
||||||
@ -73,11 +61,24 @@ export default function TrendingNotes({ count = Infinity, small = false }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const renderList = () => {
|
const renderList = () => {
|
||||||
return filteredAndLimitedPosts().map(e =>
|
return filteredAndLimitedPosts.map(e =>
|
||||||
small ? (
|
small ? (
|
||||||
<ShortNote key={e.id} event={e as TaggedNostrEvent} />
|
<ShortNote key={e.id} event={e as TaggedNostrEvent} />
|
||||||
) : (
|
) : (
|
||||||
<Note key={e.id} data={e as TaggedNostrEvent} related={related?.data ?? []} depth={0} options={options} />
|
<Note
|
||||||
|
key={e.id}
|
||||||
|
data={e as TaggedNostrEvent}
|
||||||
|
related={related?.data ?? []}
|
||||||
|
depth={0}
|
||||||
|
options={{
|
||||||
|
showFooter: !small,
|
||||||
|
showReactionsLink: !small,
|
||||||
|
showMedia: !small,
|
||||||
|
longFormPreview: !small,
|
||||||
|
truncate: small,
|
||||||
|
showContextMenu: !small,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -1,32 +1,29 @@
|
|||||||
import { ReactNode, useEffect, useState } from "react";
|
import { ReactNode } from "react";
|
||||||
import { HexKey } from "@snort/system";
|
import { HexKey } from "@snort/system";
|
||||||
|
|
||||||
import FollowListBase from "@/Element/User/FollowListBase";
|
import FollowListBase from "@/Element/User/FollowListBase";
|
||||||
import PageSpinner from "@/Element/PageSpinner";
|
import PageSpinner from "@/Element/PageSpinner";
|
||||||
import NostrBandApi from "@/External/NostrBand";
|
import NostrBandApi from "@/External/NostrBand";
|
||||||
import { ErrorOrOffline } from "../ErrorOrOffline";
|
import { ErrorOrOffline } from "../ErrorOrOffline";
|
||||||
|
import useCachedFetch from "@/Hooks/useCachedFetch";
|
||||||
|
|
||||||
export default function TrendingUsers({ title, count = Infinity }: { title?: ReactNode; count?: number }) {
|
export default function TrendingUsers({ title, count = Infinity }: { title?: ReactNode; count?: number }) {
|
||||||
const [userList, setUserList] = useState<HexKey[]>();
|
|
||||||
const [error, setError] = useState<Error>();
|
|
||||||
|
|
||||||
async function loadTrendingUsers() {
|
|
||||||
const api = new NostrBandApi();
|
const api = new NostrBandApi();
|
||||||
const users = await api.trendingProfiles();
|
const trendingProfilesUrl = api.trendingProfilesUrl();
|
||||||
const keys = users.profiles.map(a => a.pubkey).slice(0, count); // Limit the user list to the count
|
const storageKey = `nostr-band-${trendingProfilesUrl}`;
|
||||||
setUserList(keys);
|
|
||||||
|
const {
|
||||||
|
data: trendingUsersData,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
} = useCachedFetch(trendingProfilesUrl, storageKey, data => data.profiles.map(a => a.pubkey));
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return <ErrorOrOffline error={error} onRetry={() => {}} className="p" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
if (isLoading || !trendingUsersData) {
|
||||||
loadTrendingUsers().catch(e => {
|
return <PageSpinner />;
|
||||||
if (e instanceof Error) {
|
|
||||||
setError(e);
|
|
||||||
}
|
}
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (error) return <ErrorOrOffline error={error} onRetry={loadTrendingUsers} className="p" />;
|
return <FollowListBase pubkeys={trendingUsersData.slice(0, count) as HexKey[]} showAbout={true} title={title} />;
|
||||||
if (!userList) return <PageSpinner />;
|
|
||||||
|
|
||||||
return <FollowListBase pubkeys={userList} showAbout={true} title={title} />;
|
|
||||||
}
|
}
|
||||||
|
103
packages/app/src/External/NostrBand.ts
vendored
103
packages/app/src/External/NostrBand.ts
vendored
@ -1,105 +1,20 @@
|
|||||||
import { throwIfOffline } from "@snort/shared";
|
|
||||||
import { NostrEvent } from "@snort/system";
|
|
||||||
|
|
||||||
export interface TrendingUser {
|
|
||||||
pubkey: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TrendingUserResponse {
|
|
||||||
profiles: Array<TrendingUser>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TrendingNote {
|
|
||||||
event: NostrEvent;
|
|
||||||
author: NostrEvent; // kind0 event
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TrendingNoteResponse {
|
|
||||||
notes: Array<TrendingNote>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TrendingHashtagsResponse {
|
|
||||||
hashtags: Array<{
|
|
||||||
hashtag: string;
|
|
||||||
posts: number;
|
|
||||||
}>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SuggestedFollow {
|
|
||||||
pubkey: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SuggestedFollowsResponse {
|
|
||||||
profiles: Array<SuggestedFollow>;
|
|
||||||
}
|
|
||||||
|
|
||||||
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 {
|
export default class NostrBandApi {
|
||||||
readonly #url = "https://api.nostr.band";
|
readonly #url = "https://api.nostr.band";
|
||||||
readonly #supportedLangs = ["en", "de", "ja", "zh", "th", "pt", "es", "fr"];
|
readonly #supportedLangs = ["en", "de", "ja", "zh", "th", "pt", "es", "fr"];
|
||||||
async trendingProfiles() {
|
|
||||||
return await this.#json<TrendingUserResponse>("GET", "/v0/trending/profiles");
|
trendingProfilesUrl() {
|
||||||
|
return `${this.#url}/v0/trending/profiles`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async trendingNotes(lang?: string) {
|
trendingNotesUrl(lang?: string) {
|
||||||
if (lang && this.#supportedLangs.includes(lang)) {
|
return `${this.#url}/v0/trending/notes${lang && this.#supportedLangs.includes(lang) ? `?lang=${lang}` : ""}`;
|
||||||
return await this.#json<TrendingNoteResponse>("GET", `/v0/trending/notes?lang=${lang}`);
|
|
||||||
}
|
|
||||||
return await this.#json<TrendingNoteResponse>("GET", "/v0/trending/notes");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async sugguestedFollows(pubkey: string) {
|
suggestedFollowsUrl(pubkey: string) {
|
||||||
return await this.#json<SuggestedFollowsResponse>("GET", `/v0/suggested/profiles/${pubkey}`);
|
return `${this.#url}/v0/suggested/profiles/${pubkey}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async trendingHashtags(lang?: string) {
|
trendingHashtagsUrl(lang?: string) {
|
||||||
if (lang && this.#supportedLangs.includes(lang)) {
|
return `${this.#url}/v0/trending/hashtags${lang && this.#supportedLangs.includes(lang) ? `?lang=${lang}` : ""}`;
|
||||||
return await this.#json<TrendingHashtagsResponse>("GET", `/v0/trending/hashtags?lang=${lang}`);
|
|
||||||
}
|
|
||||||
return await this.#json<TrendingHashtagsResponse>("GET", "/v0/trending/hashtags");
|
|
||||||
}
|
|
||||||
|
|
||||||
async #json<T>(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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
44
packages/app/src/External/SemisolDev.ts
vendored
44
packages/app/src/External/SemisolDev.ts
vendored
@ -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 {
|
export default class SemisolDevApi {
|
||||||
readonly #url = "https://api.semisol.dev";
|
readonly #url = "https://api.semisol.dev";
|
||||||
|
|
||||||
async sugguestedFollows(pubkey: string, follows: Array<string>) {
|
suggestedFollowsUrl(pubkey: string, follows: Array<string>) {
|
||||||
return await this.#json<RecommendedProfilesResponse>("POST", "/nosgraph/v1/recommend", {
|
const query = new URLSearchParams({ pubkey, follows: JSON.stringify(follows) });
|
||||||
pubkey,
|
return `${this.#url}/nosgraph/v1/recommend?${query.toString()}`;
|
||||||
exclude: [],
|
|
||||||
following: follows,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async #json<T>(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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
46
packages/app/src/Hooks/useCachedFetch.ts
Normal file
46
packages/app/src/Hooks/useCachedFetch.ts
Normal file
@ -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;
|
@ -75,7 +75,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
html {
|
html {
|
||||||
scroll-behavior: smooth;
|
|
||||||
-webkit-tap-highlight-color: transparent;
|
-webkit-tap-highlight-color: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user