feat: quotes
This commit is contained in:
14
src/components/LoadingBar/index.tsx
Normal file
14
src/components/LoadingBar/index.tsx
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
export function LoadingBar({ className }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<div className={cn('h-0.5 w-full overflow-hidden', className)}>
|
||||||
|
<div
|
||||||
|
className="h-full w-full bg-gradient-to-r from-primary/40 from-25% via-primary via-50% to-primary/40 to-75% animate-shimmer"
|
||||||
|
style={{
|
||||||
|
backgroundSize: '400% 100%'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
61
src/components/NoteInteractions/Tabs.tsx
Normal file
61
src/components/NoteInteractions/Tabs.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="w-fit">
|
||||||
|
<div className="flex relative">
|
||||||
|
{TABS.map((tab, index) => (
|
||||||
|
<div
|
||||||
|
key={tab.value}
|
||||||
|
ref={(el) => (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)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div
|
||||||
|
className="absolute top-0 h-1 bg-primary rounded-full transition-all duration-500"
|
||||||
|
style={{
|
||||||
|
width: `${indicatorStyle.width}px`,
|
||||||
|
left: `${indicatorStyle.left}px`
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
28
src/components/NoteInteractions/index.tsx
Normal file
28
src/components/NoteInteractions/index.tsx
Normal file
@@ -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<TTabValue>('replies')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Tabs selectedTab={type} onTabChange={setType} />
|
||||||
|
<Separator />
|
||||||
|
{type === 'replies' ? (
|
||||||
|
<ReplyNoteList index={pageIndex} event={event} />
|
||||||
|
) : (
|
||||||
|
<QuoteList event={event} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
142
src/components/QuoteList/index.tsx
Normal file
142
src/components/QuoteList/index.tsx
Normal file
@@ -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<string | undefined>(undefined)
|
||||||
|
const [events, setEvents] = useState<Event[]>([])
|
||||||
|
const [showCount, setShowCount] = useState(SHOW_COUNT)
|
||||||
|
const [hasMore, setHasMore] = useState<boolean>(true)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const bottomRef = useRef<HTMLDivElement | null>(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 (
|
||||||
|
<div className={className}>
|
||||||
|
<div className="min-h-screen">
|
||||||
|
<div>
|
||||||
|
{events.slice(0, showCount).map((event) => (
|
||||||
|
<NoteCard key={event.id} className="w-full" event={event} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{hasMore || loading ? (
|
||||||
|
<div ref={bottomRef}>
|
||||||
|
<NoteCardLoadingSkeleton isPictures={false} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center text-sm text-muted-foreground mt-2">{t('no more notes')}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="h-40" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useSecondaryPage } from '@/PageManager'
|
import { useSecondaryPage } from '@/PageManager'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
import { toNote } from '@/lib/link'
|
import { toNote } from '@/lib/link'
|
||||||
import { useMuteList } from '@/providers/MuteListProvider'
|
import { useMuteList } from '@/providers/MuteListProvider'
|
||||||
import { Event } from 'nostr-tools'
|
import { Event } from 'nostr-tools'
|
||||||
@@ -84,3 +85,22 @@ export default function ReplyNote({
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function ReplyNoteSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="px-4 py-3 flex items-start space-x-2 w-full">
|
||||||
|
<Skeleton className="w-8 h-8 rounded-full shrink-0" />
|
||||||
|
<div className="w-full">
|
||||||
|
<div className="py-1">
|
||||||
|
<Skeleton className="h-3 w-16" />
|
||||||
|
</div>
|
||||||
|
<div className="my-1">
|
||||||
|
<Skeleton className="w-full h-4 my-1 mt-2" />
|
||||||
|
</div>
|
||||||
|
<div className="my-1">
|
||||||
|
<Skeleton className="w-2/3 h-4 my-1" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { Separator } from '@/components/ui/separator'
|
|
||||||
import { BIG_RELAY_URLS, ExtendedKind } from '@/constants'
|
import { BIG_RELAY_URLS, ExtendedKind } from '@/constants'
|
||||||
import {
|
import {
|
||||||
getParentEventTag,
|
getParentEventTag,
|
||||||
@@ -14,7 +13,8 @@ import client from '@/services/client.service'
|
|||||||
import { Filter, Event as NEvent, kinds } from 'nostr-tools'
|
import { Filter, Event as NEvent, kinds } from 'nostr-tools'
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
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 }
|
type TRootInfo = { type: 'event'; id: string; pubkey: string } | { type: 'I'; id: string }
|
||||||
|
|
||||||
@@ -239,15 +239,15 @@ export default function ReplyNoteList({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{(loading || (!!until && replies.length > 0)) && (
|
{loading && (replies.length === 0 ? <ReplyNoteSkeleton /> : <LoadingBar />)}
|
||||||
|
{!loading && until && (
|
||||||
<div
|
<div
|
||||||
className={`text-sm text-center text-muted-foreground mt-2 ${!loading ? 'hover:text-foreground cursor-pointer' : ''}`}
|
className={`text-sm text-center text-muted-foreground border-b py-2 ${!loading ? 'hover:text-foreground cursor-pointer' : ''}`}
|
||||||
onClick={loadMore}
|
onClick={loadMore}
|
||||||
>
|
>
|
||||||
{loading ? t('loading...') : t('load more older replies')}
|
{t('load more older replies')}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{replies.length > 0 && (loading || until) && <Separator className="mt-2" />}
|
|
||||||
<div className={className}>
|
<div className={className}>
|
||||||
{replies.slice(0, showCount).map((reply) => {
|
{replies.slice(0, showCount).map((reply) => {
|
||||||
if (!isUserTrusted(reply.pubkey)) {
|
if (!isUserTrusted(reply.pubkey)) {
|
||||||
|
|||||||
@@ -238,6 +238,7 @@ export default {
|
|||||||
'فقط عرض المحتوى من المستخدمين الذين تتابعهم والمستخدمين الذين يتابعونهم',
|
'فقط عرض المحتوى من المستخدمين الذين تتابعهم والمستخدمين الذين يتابعونهم',
|
||||||
'Followed by': 'متابع من قبل',
|
'Followed by': 'متابع من قبل',
|
||||||
'Mute user privately': 'كتم المستخدم بشكل خاص',
|
'Mute user privately': 'كتم المستخدم بشكل خاص',
|
||||||
'Mute user publicly': 'كتم المستخدم علنياً'
|
'Mute user publicly': 'كتم المستخدم علنياً',
|
||||||
|
Quotes: 'الاقتباسات'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -245,6 +245,7 @@ export default {
|
|||||||
'Nur Inhalte von Benutzern anzeigen, denen du folgst und die sie folgen',
|
'Nur Inhalte von Benutzern anzeigen, denen du folgst und die sie folgen',
|
||||||
'Followed by': 'Gefolgt von',
|
'Followed by': 'Gefolgt von',
|
||||||
'Mute user privately': 'Benutzer privat stummschalten',
|
'Mute user privately': 'Benutzer privat stummschalten',
|
||||||
'Mute user publicly': 'Benutzer öffentlich stummschalten'
|
'Mute user publicly': 'Benutzer öffentlich stummschalten',
|
||||||
|
Quotes: 'Zitate'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -238,6 +238,7 @@ export default {
|
|||||||
'Only show content from your followed users and the users they follow',
|
'Only show content from your followed users and the users they follow',
|
||||||
'Followed by': 'Followed by',
|
'Followed by': 'Followed by',
|
||||||
'Mute user privately': 'Mute user privately',
|
'Mute user privately': 'Mute user privately',
|
||||||
'Mute user publicly': 'Mute user publicly'
|
'Mute user publicly': 'Mute user publicly',
|
||||||
|
Quotes: 'Quotes'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -243,6 +243,7 @@ export default {
|
|||||||
'Solo mostrar contenido de tus usuarios seguidos y los usuarios que ellos siguen',
|
'Solo mostrar contenido de tus usuarios seguidos y los usuarios que ellos siguen',
|
||||||
'Followed by': 'Seguidos por',
|
'Followed by': 'Seguidos por',
|
||||||
'Mute user privately': 'Silenciar usuario en privado',
|
'Mute user privately': 'Silenciar usuario en privado',
|
||||||
'Mute user publicly': 'Silenciar usuario públicamente'
|
'Mute user publicly': 'Silenciar usuario públicamente',
|
||||||
|
Quotes: 'Citas'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -243,6 +243,7 @@ export default {
|
|||||||
'Afficher uniquement le contenu de vos utilisateurs suivis et des utilisateurs qu’ils suivent',
|
'Afficher uniquement le contenu de vos utilisateurs suivis et des utilisateurs qu’ils suivent',
|
||||||
'Followed by': 'Suivi par',
|
'Followed by': 'Suivi par',
|
||||||
'Mute user privately': 'Mettre l’utilisateur en sourdine en privé',
|
'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'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -242,6 +242,7 @@ export default {
|
|||||||
'Mostra solo contenuti dai tuoi utenti seguiti e dagli utenti che seguono',
|
'Mostra solo contenuti dai tuoi utenti seguiti e dagli utenti che seguono',
|
||||||
'Followed by': 'Seguito da',
|
'Followed by': 'Seguito da',
|
||||||
'Mute user privately': 'Zittisci utente privatamente',
|
'Mute user privately': 'Zittisci utente privatamente',
|
||||||
'Mute user publicly': 'Zittisci utente pubblicamente'
|
'Mute user publicly': 'Zittisci utente pubblicamente',
|
||||||
|
Quotes: 'Citazioni'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -239,6 +239,7 @@ export default {
|
|||||||
'フォローしているユーザーとそのユーザーがフォローしているユーザーのコンテンツのみを表示',
|
'フォローしているユーザーとそのユーザーがフォローしているユーザーのコンテンツのみを表示',
|
||||||
'Followed by': 'フォロワー',
|
'Followed by': 'フォロワー',
|
||||||
'Mute user privately': 'ユーザーを非公開でミュート',
|
'Mute user privately': 'ユーザーを非公開でミュート',
|
||||||
'Mute user publicly': 'ユーザーを公開でミュート'
|
'Mute user publicly': 'ユーザーを公開でミュート',
|
||||||
|
Quotes: '引用'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -241,6 +241,7 @@ export default {
|
|||||||
'Pokaż tylko treści od użytkowników, których obserwujesz i ich obserwowanych',
|
'Pokaż tylko treści od użytkowników, których obserwujesz i ich obserwowanych',
|
||||||
'Followed by': 'Obserwowany przez',
|
'Followed by': 'Obserwowany przez',
|
||||||
'Mute user privately': 'Zablokuj użytkownika prywatnie',
|
'Mute user privately': 'Zablokuj użytkownika prywatnie',
|
||||||
'Mute user publicly': 'Zablokuj użytkownika publicznie'
|
'Mute user publicly': 'Zablokuj użytkownika publicznie',
|
||||||
|
Quotes: 'Cytaty'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -241,6 +241,7 @@ export default {
|
|||||||
'Mostrar apenas conteúdo dos usuários que você segue e dos usuários que eles seguem',
|
'Mostrar apenas conteúdo dos usuários que você segue e dos usuários que eles seguem',
|
||||||
'Followed by': 'Seguido por',
|
'Followed by': 'Seguido por',
|
||||||
'Mute user privately': 'Silenciar usuário privadamente',
|
'Mute user privately': 'Silenciar usuário privadamente',
|
||||||
'Mute user publicly': 'Silenciar usuário publicamente'
|
'Mute user publicly': 'Silenciar usuário publicamente',
|
||||||
|
Quotes: 'Citações'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -242,6 +242,7 @@ export default {
|
|||||||
'Mostrar apenas conteúdo dos usuários que você segue e dos usuários que eles seguem',
|
'Mostrar apenas conteúdo dos usuários que você segue e dos usuários que eles seguem',
|
||||||
'Followed by': 'Seguido por',
|
'Followed by': 'Seguido por',
|
||||||
'Mute user privately': 'Silenciar usuário privadamente',
|
'Mute user privately': 'Silenciar usuário privadamente',
|
||||||
'Mute user publicly': 'Silenciar usuário publicamente'
|
'Mute user publicly': 'Silenciar usuário publicamente',
|
||||||
|
Quotes: 'Citações'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -243,6 +243,7 @@ export default {
|
|||||||
'Показывать только контент от пользователей, на которых вы подписаны, и от пользователей, на которых они подписаны',
|
'Показывать только контент от пользователей, на которых вы подписаны, и от пользователей, на которых они подписаны',
|
||||||
'Followed by': 'Подписан на',
|
'Followed by': 'Подписан на',
|
||||||
'Mute user privately': 'Заглушить пользователя приватно',
|
'Mute user privately': 'Заглушить пользователя приватно',
|
||||||
'Mute user publicly': 'Заглушить пользователя публично'
|
'Mute user publicly': 'Заглушить пользователя публично',
|
||||||
|
Quotes: 'Цитаты'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -239,6 +239,7 @@ export default {
|
|||||||
'仅显示您关注的用户及其关注的用户的内容',
|
'仅显示您关注的用户及其关注的用户的内容',
|
||||||
'Followed by': '关注者',
|
'Followed by': '关注者',
|
||||||
'Mute user privately': '悄悄屏蔽',
|
'Mute user privately': '悄悄屏蔽',
|
||||||
'Mute user publicly': '公开屏蔽'
|
'Mute user publicly': '公开屏蔽',
|
||||||
|
Quotes: '引用'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,13 +50,25 @@
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@media (hover: hover) and (pointer: fine) {
|
@media (hover: hover) and (pointer: fine) {
|
||||||
.clickable:hover {
|
.clickable:hover {
|
||||||
background-color: hsl(var(--muted) / 0.5);
|
background-color: hsl(var(--muted) / 0.5);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes shimmer {
|
||||||
|
0% {
|
||||||
|
background-position: 400% 0;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
background-position: 0% 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-shimmer {
|
||||||
|
animation: shimmer 3s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--background: 0 0% 100%;
|
--background: 0 0% 100%;
|
||||||
--foreground: 240 10% 3.9%;
|
--foreground: 240 10% 3.9%;
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { useSecondaryPage } from '@/PageManager'
|
import { useSecondaryPage } from '@/PageManager'
|
||||||
import ContentPreview from '@/components/ContentPreview'
|
import ContentPreview from '@/components/ContentPreview'
|
||||||
import Note from '@/components/Note'
|
import Note from '@/components/Note'
|
||||||
|
import NoteInteractions from '@/components/NoteInteractions'
|
||||||
import NoteStats from '@/components/NoteStats'
|
import NoteStats from '@/components/NoteStats'
|
||||||
import PictureNote from '@/components/PictureNote'
|
import PictureNote from '@/components/PictureNote'
|
||||||
import ReplyNoteList from '@/components/ReplyNoteList'
|
|
||||||
import UserAvatar from '@/components/UserAvatar'
|
import UserAvatar from '@/components/UserAvatar'
|
||||||
import { Card } from '@/components/ui/card'
|
import { Card } from '@/components/ui/card'
|
||||||
import { Separator } from '@/components/ui/separator'
|
import { Separator } from '@/components/ui/separator'
|
||||||
@@ -63,7 +63,7 @@ const NotePage = forwardRef(({ id, index }: { id?: string; index?: number }, ref
|
|||||||
<SecondaryPageLayout ref={ref} index={index} title={t('Note')} displayScrollToTopButton>
|
<SecondaryPageLayout ref={ref} index={index} title={t('Note')} displayScrollToTopButton>
|
||||||
<PictureNote key={`note-${event.id}`} event={event} fetchNoteStats />
|
<PictureNote key={`note-${event.id}`} event={event} fetchNoteStats />
|
||||||
<Separator className="mt-4" />
|
<Separator className="mt-4" />
|
||||||
<ReplyNoteList key={`reply-note-list-${event.id}`} index={index} event={event} />
|
<NoteInteractions key={`note-interactions-${event.id}`} pageIndex={index} event={event} />
|
||||||
</SecondaryPageLayout>
|
</SecondaryPageLayout>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -85,7 +85,7 @@ const NotePage = forwardRef(({ id, index }: { id?: string; index?: number }, ref
|
|||||||
<NoteStats className="mt-3" event={event} fetchIfNotExisting displayTopZapsAndLikes />
|
<NoteStats className="mt-3" event={event} fetchIfNotExisting displayTopZapsAndLikes />
|
||||||
</div>
|
</div>
|
||||||
<Separator className="mt-4" />
|
<Separator className="mt-4" />
|
||||||
<ReplyNoteList key={`reply-note-list-${event.id}`} index={index} event={event} />
|
<NoteInteractions key={`note-interactions-${event.id}`} pageIndex={index} event={event} />
|
||||||
</SecondaryPageLayout>
|
</SecondaryPageLayout>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user