332 lines
10 KiB
TypeScript
332 lines
10 KiB
TypeScript
import { Skeleton } from '@/components/ui/skeleton'
|
|
import { COMMENT_EVENT_KIND, PICTURE_EVENT_KIND } from '@/constants'
|
|
import { useFetchEvent } from '@/hooks'
|
|
import { extractEmbeddedNotesFromContent, extractImagesFromContent } from '@/lib/event'
|
|
import { toNote } from '@/lib/link'
|
|
import { tagNameEquals } from '@/lib/tag'
|
|
import { useSecondaryPage } from '@/PageManager'
|
|
import { useMuteList } from '@/providers/MuteListProvider'
|
|
import { useNostr } from '@/providers/NostrProvider'
|
|
import client from '@/services/client.service'
|
|
import dayjs from 'dayjs'
|
|
import { Heart, MessageCircle, Repeat, ThumbsUp } from 'lucide-react'
|
|
import { Event, kinds, nip19, validateEvent } from 'nostr-tools'
|
|
import {
|
|
forwardRef,
|
|
useCallback,
|
|
useEffect,
|
|
useImperativeHandle,
|
|
useMemo,
|
|
useRef,
|
|
useState
|
|
} from 'react'
|
|
import { useTranslation } from 'react-i18next'
|
|
import PullToRefresh from 'react-simple-pull-to-refresh'
|
|
import { embedded, embeddedNostrNpubRenderer, embeddedNostrProfileRenderer } from '../Embedded'
|
|
import { FormattedTimestamp } from '../FormattedTimestamp'
|
|
import UserAvatar from '../UserAvatar'
|
|
|
|
const LIMIT = 100
|
|
const SHOW_COUNT = 30
|
|
|
|
const NotificationList = forwardRef((_, ref) => {
|
|
const { t } = useTranslation()
|
|
const { pubkey } = useNostr()
|
|
const [refreshCount, setRefreshCount] = useState(0)
|
|
const [timelineKey, setTimelineKey] = useState<string | undefined>(undefined)
|
|
const [refreshing, setRefreshing] = useState(true)
|
|
const [notifications, setNotifications] = useState<Event[]>([])
|
|
const [showCount, setShowCount] = useState(SHOW_COUNT)
|
|
const [until, setUntil] = useState<number | undefined>(dayjs().unix())
|
|
const bottomRef = useRef<HTMLDivElement | null>(null)
|
|
useImperativeHandle(
|
|
ref,
|
|
() => ({
|
|
refresh: () => {
|
|
if (refreshing) return
|
|
setRefreshCount((count) => count + 1)
|
|
}
|
|
}),
|
|
[refreshing]
|
|
)
|
|
|
|
useEffect(() => {
|
|
if (!pubkey) {
|
|
setUntil(undefined)
|
|
return
|
|
}
|
|
|
|
const init = async () => {
|
|
setRefreshing(true)
|
|
const relayList = await client.fetchRelayList(pubkey)
|
|
let eventCount = 0
|
|
const { closer, timelineKey } = await client.subscribeTimeline(
|
|
relayList.read.length >= 4
|
|
? relayList.read
|
|
: relayList.read.concat(client.getDefaultRelayUrls()).slice(0, 4),
|
|
{
|
|
'#p': [pubkey],
|
|
kinds: [kinds.ShortTextNote, kinds.Repost, kinds.Reaction, COMMENT_EVENT_KIND],
|
|
limit: LIMIT
|
|
},
|
|
{
|
|
onEvents: (events, eosed) => {
|
|
if (eventCount > events.length) return
|
|
eventCount = events.length
|
|
setNotifications(events.filter((event) => event.pubkey !== pubkey))
|
|
if (eosed) {
|
|
setRefreshing(false)
|
|
setUntil(events.length > 0 ? events[events.length - 1].created_at - 1 : undefined)
|
|
}
|
|
},
|
|
onNew: (event) => {
|
|
if (event.pubkey === pubkey) return
|
|
setNotifications((oldEvents) => {
|
|
const index = oldEvents.findIndex(
|
|
(oldEvent) => oldEvent.created_at < event.created_at
|
|
)
|
|
if (index === -1) {
|
|
return [...oldEvents, event]
|
|
}
|
|
return [...oldEvents.slice(0, index), event, ...oldEvents.slice(index)]
|
|
})
|
|
}
|
|
}
|
|
)
|
|
setTimelineKey(timelineKey)
|
|
return closer
|
|
}
|
|
|
|
const promise = init()
|
|
return () => {
|
|
promise.then((closer) => closer?.())
|
|
}
|
|
}, [pubkey, refreshCount])
|
|
|
|
const loadMore = useCallback(async () => {
|
|
if (showCount < notifications.length) {
|
|
setShowCount((count) => count + SHOW_COUNT)
|
|
return
|
|
}
|
|
|
|
if (!pubkey || !timelineKey || !until || refreshing) return
|
|
|
|
const newNotifications = await client.loadMoreTimeline(timelineKey, until, LIMIT)
|
|
if (newNotifications.length === 0) {
|
|
setUntil(undefined)
|
|
return
|
|
}
|
|
|
|
if (newNotifications.length > 0) {
|
|
setNotifications((oldNotifications) => [
|
|
...oldNotifications,
|
|
...newNotifications.filter((event) => event.pubkey !== pubkey)
|
|
])
|
|
}
|
|
|
|
setUntil(newNotifications[newNotifications.length - 1].created_at - 1)
|
|
}, [pubkey, timelineKey, until, refreshing, showCount, notifications])
|
|
|
|
useEffect(() => {
|
|
const options = {
|
|
root: null,
|
|
rootMargin: '10px',
|
|
threshold: 1
|
|
}
|
|
|
|
const observerInstance = new IntersectionObserver((entries) => {
|
|
if (entries[0].isIntersecting) {
|
|
loadMore()
|
|
}
|
|
}, options)
|
|
|
|
const currentBottomRef = bottomRef.current
|
|
|
|
if (currentBottomRef) {
|
|
observerInstance.observe(currentBottomRef)
|
|
}
|
|
|
|
return () => {
|
|
if (observerInstance && currentBottomRef) {
|
|
observerInstance.unobserve(currentBottomRef)
|
|
}
|
|
}
|
|
}, [loadMore])
|
|
|
|
return (
|
|
<PullToRefresh
|
|
onRefresh={async () => {
|
|
setRefreshCount((count) => count + 1)
|
|
await new Promise((resolve) => setTimeout(resolve, 1000))
|
|
}}
|
|
pullingContent=""
|
|
>
|
|
<div>
|
|
{notifications.slice(0, showCount).map((notification) => (
|
|
<NotificationItem key={notification.id} notification={notification} />
|
|
))}
|
|
<div className="text-center text-sm text-muted-foreground">
|
|
{until || refreshing ? (
|
|
<div ref={bottomRef}>
|
|
<div className="flex gap-2 items-center h-11 py-2">
|
|
<Skeleton className="w-7 h-7 rounded-full" />
|
|
<Skeleton className="w-6 h-6 rounded-full" />
|
|
<Skeleton className="h-6 flex-1 w-0" />
|
|
</div>
|
|
</div>
|
|
) : (
|
|
t('no more notifications')
|
|
)}
|
|
</div>
|
|
</div>
|
|
</PullToRefresh>
|
|
)
|
|
})
|
|
NotificationList.displayName = 'NotificationList'
|
|
export default NotificationList
|
|
|
|
function NotificationItem({ notification }: { notification: Event }) {
|
|
const { mutePubkeys } = useMuteList()
|
|
if (mutePubkeys.includes(notification.pubkey)) {
|
|
return null
|
|
}
|
|
if (notification.kind === kinds.Reaction) {
|
|
return <ReactionNotification notification={notification} />
|
|
}
|
|
if (notification.kind === kinds.ShortTextNote) {
|
|
return <ReplyNotification notification={notification} />
|
|
}
|
|
if (notification.kind === kinds.Repost) {
|
|
return <RepostNotification notification={notification} />
|
|
}
|
|
if (notification.kind === COMMENT_EVENT_KIND) {
|
|
return <CommentNotification notification={notification} />
|
|
}
|
|
return null
|
|
}
|
|
|
|
function ReactionNotification({ notification }: { notification: Event }) {
|
|
const { push } = useSecondaryPage()
|
|
const bech32Id = useMemo(() => {
|
|
const eTag = notification.tags.findLast(tagNameEquals('e'))
|
|
const pTag = notification.tags.find(tagNameEquals('p'))
|
|
const eventId = eTag?.[1]
|
|
const author = pTag?.[1]
|
|
return eventId
|
|
? nip19.neventEncode(author ? { id: eventId, author } : { id: eventId })
|
|
: undefined
|
|
}, [notification])
|
|
const { event } = useFetchEvent(bech32Id)
|
|
if (!event || !bech32Id || ![kinds.ShortTextNote, PICTURE_EVENT_KIND].includes(event.kind)) {
|
|
return null
|
|
}
|
|
|
|
return (
|
|
<div
|
|
className="flex items-center justify-between cursor-pointer py-2"
|
|
onClick={() => push(toNote(bech32Id))}
|
|
>
|
|
<div className="flex gap-2 items-center flex-1">
|
|
<UserAvatar userId={notification.pubkey} size="small" />
|
|
<Heart size={24} className="text-red-400" />
|
|
<div>{notification.content === '+' ? <ThumbsUp size={14} /> : notification.content}</div>
|
|
<ContentPreview event={event} />
|
|
</div>
|
|
<div className="text-muted-foreground">
|
|
<FormattedTimestamp timestamp={notification.created_at} short />
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function ReplyNotification({ notification }: { notification: Event }) {
|
|
const { push } = useSecondaryPage()
|
|
return (
|
|
<div
|
|
className="flex gap-2 items-center cursor-pointer py-2"
|
|
onClick={() => push(toNote(notification))}
|
|
>
|
|
<UserAvatar userId={notification.pubkey} size="small" />
|
|
<MessageCircle size={24} className="text-blue-400" />
|
|
<ContentPreview event={notification} />
|
|
<div className="text-muted-foreground">
|
|
<FormattedTimestamp timestamp={notification.created_at} short />
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function RepostNotification({ notification }: { notification: Event }) {
|
|
const { push } = useSecondaryPage()
|
|
const event = useMemo(() => {
|
|
try {
|
|
const event = JSON.parse(notification.content) as Event
|
|
const isValid = validateEvent(event)
|
|
if (!isValid) return null
|
|
client.addEventToCache(event)
|
|
return event
|
|
} catch {
|
|
return null
|
|
}
|
|
}, [notification.content])
|
|
if (!event) return null
|
|
|
|
return (
|
|
<div
|
|
className="flex gap-2 items-center cursor-pointer py-2"
|
|
onClick={() => push(toNote(event))}
|
|
>
|
|
<UserAvatar userId={notification.pubkey} size="small" />
|
|
<Repeat size={24} className="text-green-400" />
|
|
<ContentPreview event={event} />
|
|
<div className="text-muted-foreground">
|
|
<FormattedTimestamp timestamp={notification.created_at} short />
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function CommentNotification({ notification }: { notification: Event }) {
|
|
const { push } = useSecondaryPage()
|
|
const rootEventId = notification.tags.find(tagNameEquals('E'))?.[1]
|
|
const rootPubkey = notification.tags.find(tagNameEquals('P'))?.[1]
|
|
const rootKind = notification.tags.find(tagNameEquals('K'))?.[1]
|
|
if (
|
|
!rootEventId ||
|
|
!rootPubkey ||
|
|
!rootKind ||
|
|
![kinds.ShortTextNote, PICTURE_EVENT_KIND].includes(parseInt(rootKind))
|
|
) {
|
|
return null
|
|
}
|
|
|
|
return (
|
|
<div
|
|
className="flex gap-2 items-center cursor-pointer py-2"
|
|
onClick={() => push(toNote({ id: rootEventId, pubkey: rootPubkey }))}
|
|
>
|
|
<UserAvatar userId={notification.pubkey} size="small" />
|
|
<MessageCircle size={24} className="text-blue-400" />
|
|
<ContentPreview event={notification} />
|
|
<div className="text-muted-foreground">
|
|
<FormattedTimestamp timestamp={notification.created_at} short />
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function ContentPreview({ event }: { event?: Event }) {
|
|
const { t } = useTranslation()
|
|
const content = useMemo(() => {
|
|
if (!event) return null
|
|
const { contentWithoutEmbeddedNotes } = extractEmbeddedNotesFromContent(event.content)
|
|
const { contentWithoutImages, images } = extractImagesFromContent(contentWithoutEmbeddedNotes)
|
|
return embedded(contentWithoutImages + (images?.length ? `[${t('image')}]` : ''), [
|
|
embeddedNostrProfileRenderer,
|
|
embeddedNostrNpubRenderer
|
|
])
|
|
}, [event])
|
|
if (!event) return null
|
|
|
|
return <div className="truncate flex-1 w-0">{content}</div>
|
|
}
|