From 1e6e37f5e532b4506d30ff1130234a8ebfbde9fc Mon Sep 17 00:00:00 2001 From: codytseng Date: Sun, 6 Apr 2025 00:34:32 +0800 Subject: [PATCH] feat: following's favoriate relays --- .../FollowingFavoriteRelayList/index.tsx | 96 +++++++++++++++++++ src/components/RelayList/index.tsx | 22 +---- src/components/RelaySimpleInfo/index.tsx | 40 +++++++- src/i18n/locales/ar.ts | 5 +- src/i18n/locales/de.ts | 5 +- src/i18n/locales/en.ts | 5 +- src/i18n/locales/es.ts | 5 +- src/i18n/locales/fr.ts | 5 +- src/i18n/locales/it.ts | 5 +- src/i18n/locales/ja.ts | 5 +- src/i18n/locales/pl.ts | 5 +- src/i18n/locales/pt-BR.ts | 5 +- src/i18n/locales/pt-PT.ts | 5 +- src/i18n/locales/ru.ts | 5 +- src/i18n/locales/zh.ts | 5 +- src/pages/primary/ExplorePage/index.tsx | 52 +++++++++- src/services/client.service.ts | 44 ++++++++- src/services/relay-info.service.ts | 3 + 18 files changed, 280 insertions(+), 37 deletions(-) create mode 100644 src/components/FollowingFavoriteRelayList/index.tsx diff --git a/src/components/FollowingFavoriteRelayList/index.tsx b/src/components/FollowingFavoriteRelayList/index.tsx new file mode 100644 index 00000000..30b8f620 --- /dev/null +++ b/src/components/FollowingFavoriteRelayList/index.tsx @@ -0,0 +1,96 @@ +import { toRelay } from '@/lib/link' +import { useSecondaryPage } from '@/PageManager' +import { useNostr } from '@/providers/NostrProvider' +import client from '@/services/client.service' +import relayInfoService from '@/services/relay-info.service' +import { TNip66RelayInfo } from '@/types' +import { useEffect, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import RelaySimpleInfo, { RelaySimpleInfoSkeleton } from '../RelaySimpleInfo' + +const SHOW_COUNT = 10 + +export default function FollowingFavoriteRelayList() { + const { t } = useTranslation() + const { push } = useSecondaryPage() + const { pubkey } = useNostr() + const [loading, setLoading] = useState(true) + const [relays, setRelays] = useState<(TNip66RelayInfo & { users: string[] })[]>([]) + const [showCount, setShowCount] = useState(SHOW_COUNT) + const bottomRef = useRef(null) + + useEffect(() => { + setLoading(true) + + const init = async () => { + if (!pubkey) return + + const relayMap = + (await client.fetchFollowingFavoriteRelays(pubkey)) ?? new Map>() + const relayUrls = Array.from(relayMap.keys()) + const relayInfos = await relayInfoService.getRelayInfos(relayUrls ?? []) + setRelays( + (relayInfos.filter(Boolean) as TNip66RelayInfo[]) + .map((relayInfo) => { + const users = Array.from(relayMap.get(relayInfo.url) ?? []) + return { + ...relayInfo, + users + } + }) + .sort((a, b) => b.users.length - a.users.length) + ) + } + init().finally(() => { + setLoading(false) + }) + }, [pubkey]) + + useEffect(() => { + const options = { + root: null, + rootMargin: '10px', + threshold: 1 + } + + const observerInstance = new IntersectionObserver((entries) => { + if (entries[0].isIntersecting && showCount < relays.length) { + setShowCount((prev) => prev + SHOW_COUNT) + } + }, options) + + const currentBottomRef = bottomRef.current + if (currentBottomRef) { + observerInstance.observe(currentBottomRef) + } + + return () => { + if (observerInstance && currentBottomRef) { + observerInstance.unobserve(currentBottomRef) + } + } + }, [showCount, relays]) + + return ( +
+ {relays.slice(0, showCount).map((relay) => ( + { + e.stopPropagation() + push(toRelay(relay.url)) + }} + /> + ))} + {showCount < relays.length &&
} + {loading && } + {!loading && ( +
+ {relays.length === 0 ? t('no relays found') : t('no more relays')} +
+ )} +
+ ) +} diff --git a/src/components/RelayList/index.tsx b/src/components/RelayList/index.tsx index f22b930e..6bf9b219 100644 --- a/src/components/RelayList/index.tsx +++ b/src/components/RelayList/index.tsx @@ -1,11 +1,10 @@ -import { Skeleton } from '@/components/ui/skeleton' import { toRelay } from '@/lib/link' import { useSecondaryPage } from '@/PageManager' import relayInfoService from '@/services/relay-info.service' import { TNip66RelayInfo } from '@/types' import { useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' -import RelaySimpleInfo from '../RelaySimpleInfo' +import RelaySimpleInfo, { RelaySimpleInfoSkeleton } from '../RelaySimpleInfo' import SearchInput from '../SearchInput' export default function RelayList() { @@ -69,7 +68,7 @@ export default function RelayList() { return (
-
+
{relays.slice(0, showCount).map((relay) => ( @@ -84,22 +83,7 @@ export default function RelayList() { /> ))} {showCount < relays.length &&
} - {loading && ( -
-
-
- -
- - -
-
- -
- - -
- )} + {loading && } {!loading && relays.length === 0 && (
{t('no relays found')}
)} diff --git a/src/components/RelaySimpleInfo/index.tsx b/src/components/RelaySimpleInfo/index.tsx index dfb300a6..3e30d368 100644 --- a/src/components/RelaySimpleInfo/index.tsx +++ b/src/components/RelaySimpleInfo/index.tsx @@ -1,9 +1,12 @@ +import { Skeleton } from '@/components/ui/skeleton' import { cn } from '@/lib/utils' import { TNip66RelayInfo } from '@/types' +import { HTMLProps } from 'react' +import { useTranslation } from 'react-i18next' import RelayBadges from '../RelayBadges' import RelayIcon from '../RelayIcon' import SaveRelayDropdownMenu from '../SaveRelayDropdownMenu' -import { HTMLProps } from 'react' +import { SimpleUserAvatar } from '../UserAvatar' export default function RelaySimpleInfo({ relayInfo, @@ -11,9 +14,11 @@ export default function RelaySimpleInfo({ className, ...props }: HTMLProps & { - relayInfo?: TNip66RelayInfo + relayInfo?: TNip66RelayInfo & { users?: string[] } hideBadge?: boolean }) { + const { t } = useTranslation() + return (
@@ -30,6 +35,37 @@ export default function RelaySimpleInfo({
{!hideBadge && relayInfo && } {!!relayInfo?.description &&
{relayInfo.description}
} + {!!relayInfo?.users?.length && ( +
+
{t('Favorited by')}
+
+ {relayInfo.users.slice(0, 10).map((user) => ( + + ))} + {relayInfo.users.length > 10 && ( +
+ +{relayInfo.users.length - 10} +
+ )} +
+
+ )} +
+ ) +} + +export function RelaySimpleInfoSkeleton() { + return ( +
+
+ +
+ + +
+
+ +
) } diff --git a/src/i18n/locales/ar.ts b/src/i18n/locales/ar.ts index aeb4fd1b..d06db8ee 100644 --- a/src/i18n/locales/ar.ts +++ b/src/i18n/locales/ar.ts @@ -203,6 +203,9 @@ export default { 'Not found the note': 'لم يتم العثور على الملاحظة', 'no more replies': 'لا توجد مزيد من الردود', 'Relay sets': 'مجموعات الريلاي', - 'Favorite Relays': 'الريلايات المفضلة' + 'Favorite Relays': 'الريلايات المفضلة', + "Following's Favorites": 'المفضلات من المتابعين', + 'no more relays': 'لا توجد مزيد من الريلايات', + 'Favorited by': 'المفضلة من قبل' } } diff --git a/src/i18n/locales/de.ts b/src/i18n/locales/de.ts index d225a782..bb2e0827 100644 --- a/src/i18n/locales/de.ts +++ b/src/i18n/locales/de.ts @@ -207,6 +207,9 @@ export default { 'Not found the note': 'Die Notiz wurde nicht gefunden', 'no more replies': 'keine weiteren Antworten', 'Relay sets': 'Relay-Sets', - 'Favorite Relays': 'Lieblings-Relays' + 'Favorite Relays': 'Lieblings-Relays', + "Following's Favorites": 'Favoriten der Folgenden', + 'no more relays': 'keine weiteren Relays', + 'Favorited by': 'Favorisiert von' } } diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 62c0f5bb..f3b92e8e 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -203,6 +203,9 @@ export default { 'Not found the note': 'Not found the note', 'no more replies': 'no more replies', 'Relay sets': 'Relay sets', - 'Favorite Relays': 'Favorite Relays' + 'Favorite Relays': 'Favorite Relays', + "Following's Favorites": "Following's Favorites", + 'no more relays': 'no more relays', + 'Favorited by': 'Favorited by' } } diff --git a/src/i18n/locales/es.ts b/src/i18n/locales/es.ts index 001ed97b..ecd3fafe 100644 --- a/src/i18n/locales/es.ts +++ b/src/i18n/locales/es.ts @@ -206,6 +206,9 @@ export default { 'Not found the note': 'No se encontró la nota', 'no more replies': 'no hay más respuestas', 'Relay sets': 'Conjuntos de relés', - 'Favorite Relays': 'Relés favoritos' + 'Favorite Relays': 'Relés favoritos', + "Following's Favorites": 'Favoritos de los seguidos', + 'no more relays': 'no hay más relés', + 'Favorited by': 'Favoritado por' } } diff --git a/src/i18n/locales/fr.ts b/src/i18n/locales/fr.ts index 1b9635f1..da4e65c3 100644 --- a/src/i18n/locales/fr.ts +++ b/src/i18n/locales/fr.ts @@ -206,6 +206,9 @@ export default { 'Not found the note': 'Note introuvable', 'no more replies': 'aucune autre réponse', 'Relay sets': 'Groupes de relais', - 'Favorite Relays': 'Relais favoris' + 'Favorite Relays': 'Relais favoris', + "Following's Favorites": "Following's Favorites", + 'no more relays': 'aucun autre relais', + 'Favorited by': 'Favorisé par' } } diff --git a/src/i18n/locales/it.ts b/src/i18n/locales/it.ts index 733a78bf..d92299ff 100644 --- a/src/i18n/locales/it.ts +++ b/src/i18n/locales/it.ts @@ -206,6 +206,9 @@ export default { 'Not found the note': 'Non è stata trovata la nota', 'no more replies': 'niente più repliche', 'Relay sets': 'Set di Relay', - 'Favorite Relays': 'Relay preferiti' + 'Favorite Relays': 'Relay preferiti', + "Following's Favorites": 'Preferiti dei seguiti', + 'no more relays': 'niente più relay', + 'Favorited by': 'Preferito da' } } diff --git a/src/i18n/locales/ja.ts b/src/i18n/locales/ja.ts index b4cd7997..f439b7e4 100644 --- a/src/i18n/locales/ja.ts +++ b/src/i18n/locales/ja.ts @@ -204,6 +204,9 @@ export default { 'Not found the note': 'ノートが見つかりません', 'no more replies': 'これ以上の返信はありません', 'Relay sets': 'リレイセット', - 'Favorite Relays': 'お気に入りのリレイ' + 'Favorite Relays': 'お気に入りのリレイ', + "Following's Favorites": 'フォロー中のお気に入り', + 'no more relays': 'これ以上のリレイはありません', + 'Favorited by': 'お気に入り' } } diff --git a/src/i18n/locales/pl.ts b/src/i18n/locales/pl.ts index e597e395..2f1244a7 100644 --- a/src/i18n/locales/pl.ts +++ b/src/i18n/locales/pl.ts @@ -205,6 +205,9 @@ export default { 'Not found the note': 'Nie znaleziono wpisu', 'no more replies': 'brak kolejnych odpowiedzi', 'Relay sets': 'Zestawy transmiterów', - 'Favorite Relays': 'Ulubione transmitery' + 'Favorite Relays': 'Ulubione transmitery', + "Following's Favorites": 'Ulubione transmitery obserwowanych', + 'no more relays': 'brak kolejnych transmiterów', + 'Favorited by': 'Ulubione przez' } } diff --git a/src/i18n/locales/pt-BR.ts b/src/i18n/locales/pt-BR.ts index 21d5acca..114be3b8 100644 --- a/src/i18n/locales/pt-BR.ts +++ b/src/i18n/locales/pt-BR.ts @@ -205,6 +205,9 @@ export default { 'Not found the note': 'Nota não encontrada', 'no more replies': 'não há mais respostas', 'Relay sets': 'Conjuntos de relé', - 'Favorite Relays': 'Relés favoritos' + 'Favorite Relays': 'Relés favoritos', + "Following's Favorites": 'Favoritos de quem você segue', + 'no more relays': 'não há mais relés', + 'Favorited by': 'Favoritado por' } } diff --git a/src/i18n/locales/pt-PT.ts b/src/i18n/locales/pt-PT.ts index 2b70ff21..f03bed69 100644 --- a/src/i18n/locales/pt-PT.ts +++ b/src/i18n/locales/pt-PT.ts @@ -206,6 +206,9 @@ export default { 'Not found the note': 'Nota não encontrada', 'no more replies': 'não há mais respostas', 'Relay sets': 'Conjuntos de Relé', - 'Favorite Relays': 'Relés Favoritos' + 'Favorite Relays': 'Relés Favoritos', + "Following's Favorites": 'Favoritos de quem você segue', + 'no more relays': 'não há mais relés', + 'Favorited by': 'Favoritado por' } } diff --git a/src/i18n/locales/ru.ts b/src/i18n/locales/ru.ts index be42dff8..6c309ce5 100644 --- a/src/i18n/locales/ru.ts +++ b/src/i18n/locales/ru.ts @@ -207,6 +207,9 @@ export default { 'Not found the note': 'Заметка не найдена', 'no more replies': 'больше нет ответов', 'Relay sets': 'Наборы ретрансляторов', - 'Favorite Relays': 'Избранные ретрансляторы' + 'Favorite Relays': 'Избранные ретрансляторы', + "Following's Favorites": 'Избранные ретрансляторы подписчиков', + 'no more relays': 'больше нет ретрансляторов', + 'Favorited by': 'Избранные у' } } diff --git a/src/i18n/locales/zh.ts b/src/i18n/locales/zh.ts index 4a03450e..b603f279 100644 --- a/src/i18n/locales/zh.ts +++ b/src/i18n/locales/zh.ts @@ -204,6 +204,9 @@ export default { 'Not found the note': '未找到该笔记', 'no more replies': '没有更多回复了', 'Relay sets': '服务器组', - 'Favorite Relays': '收藏的服务器' + 'Favorite Relays': '收藏的服务器', + "Following's Favorites": '关注人的收藏', + 'no more relays': '没有更多服务器了', + 'Favorited by': '收藏自' } } diff --git a/src/pages/primary/ExplorePage/index.tsx b/src/pages/primary/ExplorePage/index.tsx index 21f86740..fd0f677b 100644 --- a/src/pages/primary/ExplorePage/index.tsx +++ b/src/pages/primary/ExplorePage/index.tsx @@ -1,10 +1,17 @@ +import FollowingFavoriteRelayList from '@/components/FollowingFavoriteRelayList' import RelayList from '@/components/RelayList' import PrimaryPageLayout from '@/layouts/PrimaryPageLayout' +import { cn } from '@/lib/utils' +import { useDeepBrowsing } from '@/providers/DeepBrowsingProvider' import { Compass } from 'lucide-react' -import { forwardRef } from 'react' +import { forwardRef, useState } from 'react' import { useTranslation } from 'react-i18next' +type TExploreTabs = 'following' | 'all' + const ExplorePage = forwardRef((_, ref) => { + const [tab, setTab] = useState('following') + return ( { titlebar={} displayScrollToTopButton > - + + {tab === 'following' ? : } ) }) @@ -29,3 +37,43 @@ function ExplorePageTitlebar() {
) } + +function Tabs({ + value, + setValue +}: { + value: TExploreTabs + setValue: (value: TExploreTabs) => void +}) { + const { t } = useTranslation() + const { deepBrowsing, lastScrollTop } = useDeepBrowsing() + + return ( +
800 ? '-translate-y-[calc(100%+12rem)]' : '' + )} + > +
+
setValue('following')} + > + {t("Following's Favorites")} +
+
setValue('all')} + > + {t('All')} +
+
+
+
+
+
+ ) +} diff --git a/src/services/client.service.ts b/src/services/client.service.ts index 228f31e6..f88db35b 100644 --- a/src/services/client.service.ts +++ b/src/services/client.service.ts @@ -1,8 +1,8 @@ -import { BIG_RELAY_URLS } from '@/constants' +import { BIG_RELAY_URLS, ExtendedKind } from '@/constants' import { getProfileFromProfileEvent, getRelayListFromRelayListEvent } from '@/lib/event' import { formatPubkey, userIdToPubkey } from '@/lib/pubkey' import { extractPubkeysFromEventTags } from '@/lib/tag' -import { isLocalNetworkUrl } from '@/lib/url' +import { isLocalNetworkUrl, isWebsocketUrl, normalizeUrl } from '@/lib/url' import { isSafari } from '@/lib/utils' import { ISigner, TProfile, TRelayList } from '@/types' import { sha256 } from '@noble/hashes/sha2' @@ -67,6 +67,13 @@ class ClientService extends EventTarget { max: 2000, fetchMethod: this._fetchFollowListEvent.bind(this) }) + private fetchFollowingFavoriteRelaysCache = new LRUCache< + string, + Promise>> + >({ + max: 10, + fetchMethod: this._fetchFollowingFavoriteRelays.bind(this) + }) private userIndex = new FlexSearch.Index({ tokenize: 'forward' @@ -822,6 +829,39 @@ class ClientService extends EventTarget { return followListEvent ? extractPubkeysFromEventTags(followListEvent.tags) : [] } + async fetchFollowingFavoriteRelays(pubkey: string) { + return this.fetchFollowingFavoriteRelaysCache.fetch(pubkey) + } + + private async _fetchFollowingFavoriteRelays(pubkey: string) { + const followings = await this.fetchFollowings(pubkey) + const favoriteRelaysEvents = await this.fetchEvents(BIG_RELAY_URLS, { + authors: followings, + kinds: [ExtendedKind.FAVORITE_RELAYS], + limit: followings.length + }) + const alreadyExistsPubkeys = new Set() + const uniqueEvents: NEvent[] = [] + favoriteRelaysEvents + .sort((a, b) => b.created_at - a.created_at) + .forEach((event) => { + if (alreadyExistsPubkeys.has(event.pubkey)) return + alreadyExistsPubkeys.add(event.pubkey) + uniqueEvents.push(event) + }) + + const relayMap = new Map>() + uniqueEvents.forEach((event) => { + event.tags.forEach(([tagName, tagValue]) => { + if (tagName === 'relay' && tagValue && isWebsocketUrl(tagValue)) { + const url = normalizeUrl(tagValue) + relayMap.set(url, (relayMap.get(url) || new Set()).add(event.pubkey)) + } + }) + }) + return relayMap + } + updateFollowListCache(event: NEvent) { this.followListCache.set(event.pubkey, Promise.resolve(event)) } diff --git a/src/services/relay-info.service.ts b/src/services/relay-info.service.ts index 59f49804..217e8ced 100644 --- a/src/services/relay-info.service.ts +++ b/src/services/relay-info.service.ts @@ -66,6 +66,9 @@ class RelayInfoService { } async getRelayInfos(urls: string[]) { + if (urls.length === 0) { + return [] + } const relayInfos = await this.fetchDataloader.loadMany(urls) return relayInfos.map((relayInfo) => (relayInfo instanceof Error ? undefined : relayInfo)) }