diff --git a/src/components/LoadingBar/index.tsx b/src/components/LoadingBar/index.tsx new file mode 100644 index 00000000..667e0324 --- /dev/null +++ b/src/components/LoadingBar/index.tsx @@ -0,0 +1,14 @@ +import { cn } from '@/lib/utils' + +export function LoadingBar({ className }: { className?: string }) { + return ( +
+
+
+ ) +} diff --git a/src/components/NoteInteractions/Tabs.tsx b/src/components/NoteInteractions/Tabs.tsx new file mode 100644 index 00000000..73086aa2 --- /dev/null +++ b/src/components/NoteInteractions/Tabs.tsx @@ -0,0 +1,61 @@ +import { cn } from '@/lib/utils' +import { useTranslation } from 'react-i18next' +import { useRef, useEffect, useState } from 'react' + +export type TTabValue = 'replies' | 'quotes' +const TABS = [ + { value: 'replies', label: 'Replies' }, + { value: 'quotes', label: 'Quotes' } +] as { value: TTabValue; label: string }[] + +export function Tabs({ + selectedTab, + onTabChange +}: { + selectedTab: TTabValue + onTabChange: (tab: TTabValue) => void +}) { + const { t } = useTranslation() + const activeIndex = TABS.findIndex((tab) => tab.value === selectedTab) + const tabRefs = useRef<(HTMLDivElement | null)[]>([]) + const [indicatorStyle, setIndicatorStyle] = useState({ width: 0, left: 0 }) + + useEffect(() => { + if (activeIndex >= 0 && tabRefs.current[activeIndex]) { + const activeTab = tabRefs.current[activeIndex] + const { offsetWidth, offsetLeft } = activeTab + const padding = 32 // 16px padding on each side + setIndicatorStyle({ + width: offsetWidth - padding, + left: offsetLeft + padding / 2 + }) + } + }, [activeIndex]) + + return ( +
+
+ {TABS.map((tab, index) => ( +
(tabRefs.current[index] = el)} + className={cn( + `text-center px-4 py-2 font-semibold clickable cursor-pointer rounded-lg`, + selectedTab === tab.value ? '' : 'text-muted-foreground' + )} + onClick={() => onTabChange(tab.value)} + > + {t(tab.label)} +
+ ))} +
+
+
+ ) +} diff --git a/src/components/NoteInteractions/index.tsx b/src/components/NoteInteractions/index.tsx new file mode 100644 index 00000000..131117c7 --- /dev/null +++ b/src/components/NoteInteractions/index.tsx @@ -0,0 +1,28 @@ +import { Separator } from '@/components/ui/separator' +import { Event } from 'nostr-tools' +import { useState } from 'react' +import QuoteList from '../QuoteList' +import ReplyNoteList from '../ReplyNoteList' +import { Tabs, TTabValue } from './Tabs' + +export default function NoteInteractions({ + pageIndex, + event +}: { + pageIndex?: number + event: Event +}) { + const [type, setType] = useState('replies') + + return ( + <> + + + {type === 'replies' ? ( + + ) : ( + + )} + + ) +} diff --git a/src/components/QuoteList/index.tsx b/src/components/QuoteList/index.tsx new file mode 100644 index 00000000..f753d112 --- /dev/null +++ b/src/components/QuoteList/index.tsx @@ -0,0 +1,142 @@ +import { BIG_RELAY_URLS } from '@/constants' +import { useNostr } from '@/providers/NostrProvider' +import client from '@/services/client.service' +import dayjs from 'dayjs' +import { Event, kinds } from 'nostr-tools' +import { useEffect, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import NoteCard, { NoteCardLoadingSkeleton } from '../NoteCard' + +const LIMIT = 100 +const SHOW_COUNT = 10 + +export default function QuoteList({ event, className }: { event: Event; className?: string }) { + const { t } = useTranslation() + const { startLogin } = useNostr() + const [timelineKey, setTimelineKey] = useState(undefined) + const [events, setEvents] = useState([]) + const [showCount, setShowCount] = useState(SHOW_COUNT) + const [hasMore, setHasMore] = useState(true) + const [loading, setLoading] = useState(true) + const bottomRef = useRef(null) + + useEffect(() => { + async function init() { + setLoading(true) + setEvents([]) + setHasMore(true) + + const relayList = await client.fetchRelayList(event.pubkey) + const relayUrls = relayList.read.concat(BIG_RELAY_URLS) + const seenOn = client.getSeenEventRelayUrls(event.id) + relayUrls.unshift(...seenOn) + + const { closer, timelineKey } = await client.subscribeTimeline( + [ + { + urls: relayUrls, + filter: { + '#q': [event.id], + kinds: [kinds.ShortTextNote], + limit: LIMIT + } + } + ], + { + onEvents: (events, eosed) => { + if (events.length > 0) { + setEvents(events) + } + if (eosed) { + setLoading(false) + setHasMore(events.length > 0) + } + }, + onNew: (event) => { + setEvents((oldEvents) => + [event, ...oldEvents].sort((a, b) => b.created_at - a.created_at) + ) + } + }, + { startLogin } + ) + setTimelineKey(timelineKey) + return closer + } + + const promise = init() + return () => { + promise.then((closer) => closer()) + } + }, [event]) + + useEffect(() => { + const options = { + root: null, + rootMargin: '10px', + threshold: 0.1 + } + + const loadMore = async () => { + if (showCount < events.length) { + setShowCount((prev) => prev + SHOW_COUNT) + // preload more + if (events.length - showCount > LIMIT / 2) { + return + } + } + + if (!timelineKey || loading || !hasMore) return + setLoading(true) + const newEvents = await client.loadMoreTimeline( + timelineKey, + events.length ? events[events.length - 1].created_at - 1 : dayjs().unix(), + LIMIT + ) + setLoading(false) + if (newEvents.length === 0) { + setHasMore(false) + return + } + setEvents((oldEvents) => [...oldEvents, ...newEvents]) + } + + const observerInstance = new IntersectionObserver((entries) => { + if (entries[0].isIntersecting && hasMore) { + loadMore() + } + }, options) + + const currentBottomRef = bottomRef.current + + if (currentBottomRef) { + observerInstance.observe(currentBottomRef) + } + + return () => { + if (observerInstance && currentBottomRef) { + observerInstance.unobserve(currentBottomRef) + } + } + }, [timelineKey, loading, hasMore, events, showCount]) + + return ( +
+
+
+ {events.slice(0, showCount).map((event) => ( + + ))} +
+ {hasMore || loading ? ( +
+ +
+ ) : ( +
{t('no more notes')}
+ )} +
+
+
+ ) +} diff --git a/src/components/ReplyNote/index.tsx b/src/components/ReplyNote/index.tsx index e786e174..cee6b1f4 100644 --- a/src/components/ReplyNote/index.tsx +++ b/src/components/ReplyNote/index.tsx @@ -1,5 +1,6 @@ import { useSecondaryPage } from '@/PageManager' import { Button } from '@/components/ui/button' +import { Skeleton } from '@/components/ui/skeleton' import { toNote } from '@/lib/link' import { useMuteList } from '@/providers/MuteListProvider' import { Event } from 'nostr-tools' @@ -84,3 +85,22 @@ export default function ReplyNote({
) } + +export function ReplyNoteSkeleton() { + return ( +
+ +
+
+ +
+
+ +
+
+ +
+
+
+ ) +} diff --git a/src/components/ReplyNoteList/index.tsx b/src/components/ReplyNoteList/index.tsx index 0ba09c8a..6ac11d74 100644 --- a/src/components/ReplyNoteList/index.tsx +++ b/src/components/ReplyNoteList/index.tsx @@ -1,4 +1,3 @@ -import { Separator } from '@/components/ui/separator' import { BIG_RELAY_URLS, ExtendedKind } from '@/constants' import { getParentEventTag, @@ -14,7 +13,8 @@ import client from '@/services/client.service' import { Filter, Event as NEvent, kinds } from 'nostr-tools' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' -import ReplyNote from '../ReplyNote' +import { LoadingBar } from '../LoadingBar' +import ReplyNote, { ReplyNoteSkeleton } from '../ReplyNote' type TRootInfo = { type: 'event'; id: string; pubkey: string } | { type: 'I'; id: string } @@ -239,15 +239,15 @@ export default function ReplyNoteList({ return ( <> - {(loading || (!!until && replies.length > 0)) && ( + {loading && (replies.length === 0 ? : )} + {!loading && until && (
- {loading ? t('loading...') : t('load more older replies')} + {t('load more older replies')}
)} - {replies.length > 0 && (loading || until) && }
{replies.slice(0, showCount).map((reply) => { if (!isUserTrusted(reply.pubkey)) { diff --git a/src/i18n/locales/ar.ts b/src/i18n/locales/ar.ts index 6641c22c..527bda44 100644 --- a/src/i18n/locales/ar.ts +++ b/src/i18n/locales/ar.ts @@ -238,6 +238,7 @@ export default { 'فقط عرض المحتوى من المستخدمين الذين تتابعهم والمستخدمين الذين يتابعونهم', 'Followed by': 'متابع من قبل', 'Mute user privately': 'كتم المستخدم بشكل خاص', - 'Mute user publicly': 'كتم المستخدم علنياً' + 'Mute user publicly': 'كتم المستخدم علنياً', + Quotes: 'الاقتباسات' } } diff --git a/src/i18n/locales/de.ts b/src/i18n/locales/de.ts index bd3d1a77..072a38cd 100644 --- a/src/i18n/locales/de.ts +++ b/src/i18n/locales/de.ts @@ -245,6 +245,7 @@ export default { 'Nur Inhalte von Benutzern anzeigen, denen du folgst und die sie folgen', 'Followed by': 'Gefolgt von', 'Mute user privately': 'Benutzer privat stummschalten', - 'Mute user publicly': 'Benutzer öffentlich stummschalten' + 'Mute user publicly': 'Benutzer öffentlich stummschalten', + Quotes: 'Zitate' } } diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 1a0acabd..7ab27122 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -238,6 +238,7 @@ export default { 'Only show content from your followed users and the users they follow', 'Followed by': 'Followed by', 'Mute user privately': 'Mute user privately', - 'Mute user publicly': 'Mute user publicly' + 'Mute user publicly': 'Mute user publicly', + Quotes: 'Quotes' } } diff --git a/src/i18n/locales/es.ts b/src/i18n/locales/es.ts index c57315d0..2e1a2eee 100644 --- a/src/i18n/locales/es.ts +++ b/src/i18n/locales/es.ts @@ -243,6 +243,7 @@ export default { 'Solo mostrar contenido de tus usuarios seguidos y los usuarios que ellos siguen', 'Followed by': 'Seguidos por', 'Mute user privately': 'Silenciar usuario en privado', - 'Mute user publicly': 'Silenciar usuario públicamente' + 'Mute user publicly': 'Silenciar usuario públicamente', + Quotes: 'Citas' } } diff --git a/src/i18n/locales/fr.ts b/src/i18n/locales/fr.ts index 349815ad..0530db2e 100644 --- a/src/i18n/locales/fr.ts +++ b/src/i18n/locales/fr.ts @@ -243,6 +243,7 @@ export default { 'Afficher uniquement le contenu de vos utilisateurs suivis et des utilisateurs qu’ils suivent', 'Followed by': 'Suivi par', 'Mute user privately': 'Mettre l’utilisateur en sourdine en privé', - 'Mute user publicly': 'Mettre l’utilisateur en sourdine publiquement' + 'Mute user publicly': 'Mettre l’utilisateur en sourdine publiquement', + Quotes: 'Citations' } } diff --git a/src/i18n/locales/it.ts b/src/i18n/locales/it.ts index 58318b22..1c5db93d 100644 --- a/src/i18n/locales/it.ts +++ b/src/i18n/locales/it.ts @@ -242,6 +242,7 @@ export default { 'Mostra solo contenuti dai tuoi utenti seguiti e dagli utenti che seguono', 'Followed by': 'Seguito da', 'Mute user privately': 'Zittisci utente privatamente', - 'Mute user publicly': 'Zittisci utente pubblicamente' + 'Mute user publicly': 'Zittisci utente pubblicamente', + Quotes: 'Citazioni' } } diff --git a/src/i18n/locales/ja.ts b/src/i18n/locales/ja.ts index a46f9d8c..01092e6d 100644 --- a/src/i18n/locales/ja.ts +++ b/src/i18n/locales/ja.ts @@ -239,6 +239,7 @@ export default { 'フォローしているユーザーとそのユーザーがフォローしているユーザーのコンテンツのみを表示', 'Followed by': 'フォロワー', 'Mute user privately': 'ユーザーを非公開でミュート', - 'Mute user publicly': 'ユーザーを公開でミュート' + 'Mute user publicly': 'ユーザーを公開でミュート', + Quotes: '引用' } } diff --git a/src/i18n/locales/pl.ts b/src/i18n/locales/pl.ts index 48c916e2..c4822ab3 100644 --- a/src/i18n/locales/pl.ts +++ b/src/i18n/locales/pl.ts @@ -241,6 +241,7 @@ export default { 'Pokaż tylko treści od użytkowników, których obserwujesz i ich obserwowanych', 'Followed by': 'Obserwowany przez', 'Mute user privately': 'Zablokuj użytkownika prywatnie', - 'Mute user publicly': 'Zablokuj użytkownika publicznie' + 'Mute user publicly': 'Zablokuj użytkownika publicznie', + Quotes: 'Cytaty' } } diff --git a/src/i18n/locales/pt-BR.ts b/src/i18n/locales/pt-BR.ts index 035690a0..1d18c3b5 100644 --- a/src/i18n/locales/pt-BR.ts +++ b/src/i18n/locales/pt-BR.ts @@ -241,6 +241,7 @@ export default { 'Mostrar apenas conteúdo dos usuários que você segue e dos usuários que eles seguem', 'Followed by': 'Seguido por', 'Mute user privately': 'Silenciar usuário privadamente', - 'Mute user publicly': 'Silenciar usuário publicamente' + 'Mute user publicly': 'Silenciar usuário publicamente', + Quotes: 'Citações' } } diff --git a/src/i18n/locales/pt-PT.ts b/src/i18n/locales/pt-PT.ts index f194ad6b..1c60a095 100644 --- a/src/i18n/locales/pt-PT.ts +++ b/src/i18n/locales/pt-PT.ts @@ -242,6 +242,7 @@ export default { 'Mostrar apenas conteúdo dos usuários que você segue e dos usuários que eles seguem', 'Followed by': 'Seguido por', 'Mute user privately': 'Silenciar usuário privadamente', - 'Mute user publicly': 'Silenciar usuário publicamente' + 'Mute user publicly': 'Silenciar usuário publicamente', + Quotes: 'Citações' } } diff --git a/src/i18n/locales/ru.ts b/src/i18n/locales/ru.ts index 80f425d3..44023693 100644 --- a/src/i18n/locales/ru.ts +++ b/src/i18n/locales/ru.ts @@ -243,6 +243,7 @@ export default { 'Показывать только контент от пользователей, на которых вы подписаны, и от пользователей, на которых они подписаны', 'Followed by': 'Подписан на', 'Mute user privately': 'Заглушить пользователя приватно', - 'Mute user publicly': 'Заглушить пользователя публично' + 'Mute user publicly': 'Заглушить пользователя публично', + Quotes: 'Цитаты' } } diff --git a/src/i18n/locales/zh.ts b/src/i18n/locales/zh.ts index 82eb8a34..630ea62f 100644 --- a/src/i18n/locales/zh.ts +++ b/src/i18n/locales/zh.ts @@ -239,6 +239,7 @@ export default { '仅显示您关注的用户及其关注的用户的内容', 'Followed by': '关注者', 'Mute user privately': '悄悄屏蔽', - 'Mute user publicly': '公开屏蔽' + 'Mute user publicly': '公开屏蔽', + Quotes: '引用' } } diff --git a/src/index.css b/src/index.css index 1518aef6..370a03e2 100644 --- a/src/index.css +++ b/src/index.css @@ -49,7 +49,6 @@ height: 0; pointer-events: none; } - @media (hover: hover) and (pointer: fine) { .clickable:hover { @@ -57,6 +56,19 @@ } } + @keyframes shimmer { + 0% { + background-position: 400% 0; + } + 100% { + background-position: 0% 0; + } + } + + .animate-shimmer { + animation: shimmer 3s ease-in-out infinite; + } + :root { --background: 0 0% 100%; --foreground: 240 10% 3.9%; diff --git a/src/pages/secondary/NotePage/index.tsx b/src/pages/secondary/NotePage/index.tsx index e6f18f5a..ded17040 100644 --- a/src/pages/secondary/NotePage/index.tsx +++ b/src/pages/secondary/NotePage/index.tsx @@ -1,9 +1,9 @@ import { useSecondaryPage } from '@/PageManager' import ContentPreview from '@/components/ContentPreview' import Note from '@/components/Note' +import NoteInteractions from '@/components/NoteInteractions' import NoteStats from '@/components/NoteStats' import PictureNote from '@/components/PictureNote' -import ReplyNoteList from '@/components/ReplyNoteList' import UserAvatar from '@/components/UserAvatar' import { Card } from '@/components/ui/card' import { Separator } from '@/components/ui/separator' @@ -63,7 +63,7 @@ const NotePage = forwardRef(({ id, index }: { id?: string; index?: number }, ref - + ) } @@ -85,7 +85,7 @@ const NotePage = forwardRef(({ id, index }: { id?: string; index?: number }, ref
- + ) })