17 changed files with 447 additions and 30 deletions
@ -1,30 +1,36 @@ |
|||||||
import dayjs from 'dayjs' |
import dayjs from 'dayjs' |
||||||
import { useTranslation } from 'react-i18next' |
import { useTranslation } from 'react-i18next' |
||||||
|
|
||||||
export function formatTimestamp(timestamp: number) { |
export function FormattedTimestamp({ |
||||||
|
timestamp, |
||||||
|
short = false |
||||||
|
}: { |
||||||
|
timestamp: number |
||||||
|
short?: boolean |
||||||
|
}) { |
||||||
const { t } = useTranslation() |
const { t } = useTranslation() |
||||||
const time = dayjs(timestamp * 1000) |
const time = dayjs(timestamp * 1000) |
||||||
const now = dayjs() |
const now = dayjs() |
||||||
|
|
||||||
const diffMonth = now.diff(time, 'month') |
const diffMonth = now.diff(time, 'month') |
||||||
if (diffMonth >= 1) { |
if (diffMonth >= 2) { |
||||||
return t('date', { timestamp: time.valueOf() }) |
return t('date', { timestamp: time.valueOf() }) |
||||||
} |
} |
||||||
|
|
||||||
const diffDay = now.diff(time, 'day') |
const diffDay = now.diff(time, 'day') |
||||||
if (diffDay >= 1) { |
if (diffDay >= 1) { |
||||||
return t('n days ago', { n: diffDay }) |
return short ? t('n d', { n: diffDay }) : t('n days ago', { n: diffDay }) |
||||||
} |
} |
||||||
|
|
||||||
const diffHour = now.diff(time, 'hour') |
const diffHour = now.diff(time, 'hour') |
||||||
if (diffHour >= 1) { |
if (diffHour >= 1) { |
||||||
return t('n hours ago', { n: diffHour }) |
return short ? t('n h', { n: diffHour }) : t('n hours ago', { n: diffHour }) |
||||||
} |
} |
||||||
|
|
||||||
const diffMinute = now.diff(time, 'minute') |
const diffMinute = now.diff(time, 'minute') |
||||||
if (diffMinute >= 1) { |
if (diffMinute >= 1) { |
||||||
return t('n minutes ago', { n: diffMinute }) |
return short ? t('n m', { n: diffMinute }) : t('n minutes ago', { n: diffMinute }) |
||||||
} |
} |
||||||
|
|
||||||
return t('just now') |
return short ? t('n s', { n: now.diff(time, 'second') }) : t('just now') |
||||||
} |
} |
||||||
@ -0,0 +1,39 @@ |
|||||||
|
import { Button } from '@renderer/components/ui/button' |
||||||
|
import { toNotifications } from '@renderer/lib/link' |
||||||
|
import { useSecondaryPage } from '@renderer/PageManager' |
||||||
|
import { Bell } from 'lucide-react' |
||||||
|
import { useTranslation } from 'react-i18next' |
||||||
|
|
||||||
|
export default function NotificationButton({ |
||||||
|
variant = 'titlebar' |
||||||
|
}: { |
||||||
|
variant?: 'sidebar' | 'titlebar' | 'small-screen-titlebar' |
||||||
|
}) { |
||||||
|
const { t } = useTranslation() |
||||||
|
const { push } = useSecondaryPage() |
||||||
|
|
||||||
|
if (variant === 'sidebar') { |
||||||
|
return ( |
||||||
|
<Button |
||||||
|
variant={variant} |
||||||
|
size={variant} |
||||||
|
title={t('notifications')} |
||||||
|
onClick={() => push(toNotifications())} |
||||||
|
> |
||||||
|
<Bell /> |
||||||
|
{t('Notifications')} |
||||||
|
</Button> |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
return ( |
||||||
|
<Button |
||||||
|
variant={variant} |
||||||
|
size={variant} |
||||||
|
title={t('notifications')} |
||||||
|
onClick={() => push(toNotifications())} |
||||||
|
> |
||||||
|
<Bell /> |
||||||
|
</Button> |
||||||
|
) |
||||||
|
} |
||||||
@ -0,0 +1,205 @@ |
|||||||
|
import { useFetchEvent } from '@renderer/hooks' |
||||||
|
import { toNote } from '@renderer/lib/link' |
||||||
|
import { tagNameEquals } from '@renderer/lib/tag' |
||||||
|
import { useSecondaryPage } from '@renderer/PageManager' |
||||||
|
import { useNostr } from '@renderer/providers/NostrProvider' |
||||||
|
import client from '@renderer/services/client.service' |
||||||
|
import dayjs from 'dayjs' |
||||||
|
import { Heart, MessageCircle, Repeat } from 'lucide-react' |
||||||
|
import { Event, kinds, nip19, validateEvent } from 'nostr-tools' |
||||||
|
import { useEffect, useMemo, useRef, useState } from 'react' |
||||||
|
import { useTranslation } from 'react-i18next' |
||||||
|
import { FormattedTimestamp } from '../FormattedTimestamp' |
||||||
|
import UserAvatar from '../UserAvatar' |
||||||
|
|
||||||
|
const LIMIT = 50 |
||||||
|
|
||||||
|
export default function NotificationList() { |
||||||
|
const { t } = useTranslation() |
||||||
|
const { pubkey } = useNostr() |
||||||
|
const [initialized, setInitialized] = useState(false) |
||||||
|
const [notifications, setNotifications] = useState<Event[]>([]) |
||||||
|
const [until, setUntil] = useState<number>(dayjs().unix()) |
||||||
|
const bottomRef = useRef<HTMLDivElement | null>(null) |
||||||
|
const observer = useRef<IntersectionObserver | null>(null) |
||||||
|
const [hasMore, setHasMore] = useState(true) |
||||||
|
|
||||||
|
useEffect(() => { |
||||||
|
if (!pubkey) { |
||||||
|
setHasMore(false) |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
const init = async () => { |
||||||
|
setHasMore(true) |
||||||
|
const subCloser = await client.subscribeNotifications(pubkey, LIMIT, { |
||||||
|
onNotifications: (events, isCache) => { |
||||||
|
setNotifications(events) |
||||||
|
setUntil(events.length ? events[events.length - 1].created_at - 1 : dayjs().unix()) |
||||||
|
if (!isCache) { |
||||||
|
setInitialized(true) |
||||||
|
} |
||||||
|
}, |
||||||
|
onNew: (event) => { |
||||||
|
setNotifications((oldEvents) => [event, ...oldEvents]) |
||||||
|
} |
||||||
|
}) |
||||||
|
|
||||||
|
return subCloser |
||||||
|
} |
||||||
|
|
||||||
|
const promise = init() |
||||||
|
return () => { |
||||||
|
promise.then((closer) => closer?.()) |
||||||
|
} |
||||||
|
}, [pubkey]) |
||||||
|
|
||||||
|
useEffect(() => { |
||||||
|
if (!initialized) return |
||||||
|
|
||||||
|
const options = { |
||||||
|
root: null, |
||||||
|
rootMargin: '10px', |
||||||
|
threshold: 1 |
||||||
|
} |
||||||
|
|
||||||
|
observer.current = new IntersectionObserver((entries) => { |
||||||
|
if (entries[0].isIntersecting && hasMore) { |
||||||
|
loadMore() |
||||||
|
} |
||||||
|
}, options) |
||||||
|
|
||||||
|
if (bottomRef.current) { |
||||||
|
observer.current.observe(bottomRef.current) |
||||||
|
} |
||||||
|
|
||||||
|
return () => { |
||||||
|
if (observer.current && bottomRef.current) { |
||||||
|
observer.current.unobserve(bottomRef.current) |
||||||
|
} |
||||||
|
} |
||||||
|
}, [until, initialized, hasMore]) |
||||||
|
|
||||||
|
const loadMore = async () => { |
||||||
|
if (!pubkey) return |
||||||
|
const notifications = await client.fetchMoreNotifications(pubkey, until, LIMIT) |
||||||
|
if (notifications.length === 0) { |
||||||
|
setHasMore(false) |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
if (notifications.length > 0) { |
||||||
|
setNotifications((oldNotifications) => [...oldNotifications, ...notifications]) |
||||||
|
} |
||||||
|
|
||||||
|
setUntil(notifications[notifications.length - 1].created_at - 1) |
||||||
|
} |
||||||
|
|
||||||
|
return ( |
||||||
|
<div className=""> |
||||||
|
{notifications.map((notification, index) => ( |
||||||
|
<NotificationItem key={index} notification={notification} /> |
||||||
|
))} |
||||||
|
<div className="text-center text-sm text-muted-foreground"> |
||||||
|
{hasMore ? <div ref={bottomRef}>{t('loading...')}</div> : t('no more notifications')} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
function NotificationItem({ notification }: { notification: Event }) { |
||||||
|
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} /> |
||||||
|
} |
||||||
|
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.id]) |
||||||
|
const { event } = useFetchEvent(bech32Id) |
||||||
|
if (!event || !bech32Id || event.kind !== kinds.ShortTextNote) 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" /> |
||||||
|
<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.id))} |
||||||
|
> |
||||||
|
<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 |
||||||
|
} |
||||||
|
}, []) |
||||||
|
if (!event) return null |
||||||
|
|
||||||
|
return ( |
||||||
|
<div |
||||||
|
className="flex gap-2 items-center cursor-pointer py-2" |
||||||
|
onClick={() => push(toNote(event.id))} |
||||||
|
> |
||||||
|
<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 ContentPreview({ event }: { event?: Event }) { |
||||||
|
if (!event || event.kind !== kinds.ShortTextNote) return null |
||||||
|
|
||||||
|
return <div className="truncate flex-1 w-0">{event.content}</div> |
||||||
|
} |
||||||
@ -0,0 +1,15 @@ |
|||||||
|
import NotificationList from '@renderer/components/NotificationList' |
||||||
|
import SecondaryPageLayout from '@renderer/layouts/SecondaryPageLayout' |
||||||
|
import { useTranslation } from 'react-i18next' |
||||||
|
|
||||||
|
export default function NotificationListPage() { |
||||||
|
const { t } = useTranslation() |
||||||
|
|
||||||
|
return ( |
||||||
|
<SecondaryPageLayout titlebarContent={t('notifications')}> |
||||||
|
<div className="max-sm:px-4"> |
||||||
|
<NotificationList /> |
||||||
|
</div> |
||||||
|
</SecondaryPageLayout> |
||||||
|
) |
||||||
|
} |
||||||
Loading…
Reference in new issue