feat: 💨

This commit is contained in:
codytseng
2025-02-18 14:55:58 +08:00
parent e61fd2e172
commit fe7d3a8b32
11 changed files with 158 additions and 31 deletions

View File

@@ -1,10 +1,13 @@
import { GROUP_METADATA_EVENT_KIND } from '@/constants' import { GROUP_METADATA_EVENT_KIND } from '@/constants'
import { isSupportedKind } from '@/lib/event' import { isSupportedKind } from '@/lib/event'
import { useMuteList } from '@/providers/MuteListProvider'
import { Event, kinds } from 'nostr-tools' import { Event, kinds } from 'nostr-tools'
import { useState } from 'react'
import GroupMetadataCard from './GroupMetadataCard' import GroupMetadataCard from './GroupMetadataCard'
import LiveEventCard from './LiveEventCard' import LiveEventCard from './LiveEventCard'
import LongFormArticleCard from './LongFormArticleCard' import LongFormArticleCard from './LongFormArticleCard'
import MainNoteCard from './MainNoteCard' import MainNoteCard from './MainNoteCard'
import MutedNoteCard from './MutedNoteCard'
import UnknownNoteCard from './UnknownNoteCard' import UnknownNoteCard from './UnknownNoteCard'
export default function GenericNoteCard({ export default function GenericNoteCard({
@@ -20,6 +23,21 @@ export default function GenericNoteCard({
embedded?: boolean embedded?: boolean
originalNoteId?: string originalNoteId?: string
}) { }) {
const [showMuted, setShowMuted] = useState(false)
const { mutePubkeys } = useMuteList()
if (mutePubkeys.includes(event.pubkey) && !showMuted) {
return (
<MutedNoteCard
event={event}
className={className}
reposter={reposter}
embedded={embedded}
show={() => setShowMuted(true)}
/>
)
}
if (isSupportedKind(event.kind)) { if (isSupportedKind(event.kind)) {
return ( return (
<MainNoteCard event={event} className={className} reposter={reposter} embedded={embedded} /> <MainNoteCard event={event} className={className} reposter={reposter} embedded={embedded} />

View File

@@ -0,0 +1,63 @@
import { Button } from '@/components/ui/button'
import { Separator } from '@/components/ui/separator'
import { cn } from '@/lib/utils'
import { Eye } from 'lucide-react'
import { Event } from 'nostr-tools'
import { useTranslation } from 'react-i18next'
import { FormattedTimestamp } from '../FormattedTimestamp'
import UserAvatar from '../UserAvatar'
import Username from '../Username'
import RepostDescription from './RepostDescription'
export default function MutedNoteCard({
event,
show,
reposter,
embedded,
className
}: {
event: Event
show: () => void
reposter?: string
embedded?: boolean
className?: string
}) {
const { t } = useTranslation()
return (
<div className={className}>
<div className={cn(embedded ? 'p-2 sm:p-3 border rounded-lg' : 'px-4 py-3')}>
<RepostDescription reposter={reposter} />
<div className="flex items-center space-x-2">
<UserAvatar userId={event.pubkey} size={embedded ? 'small' : 'normal'} />
<div
className={`flex-1 w-0 ${embedded ? 'flex space-x-2 items-center overflow-hidden' : ''}`}
>
<Username
userId={event.pubkey}
className={cn('font-semibold flex truncate', embedded ? 'text-sm' : '')}
skeletonClassName={embedded ? 'h-3' : 'h-4'}
/>
<div className="text-xs text-muted-foreground line-clamp-1">
<FormattedTimestamp timestamp={event.created_at} />
</div>
</div>
</div>
<div className="flex flex-col gap-2 items-center text-muted-foreground font-medium my-4">
<div>{t('This user is muted')}</div>
<Button
onClick={(e) => {
e.stopPropagation()
show()
}}
variant="outline"
>
<Eye />
{t('Temporarily display this note')}
</Button>
</div>
</div>
{!embedded && <Separator />}
</div>
)
}

View File

@@ -5,11 +5,11 @@ import { cn } from '@/lib/utils'
import { Check, Copy } from 'lucide-react' import { Check, Copy } from 'lucide-react'
import { Event } from 'nostr-tools' import { Event } from 'nostr-tools'
import { useState } from 'react' import { useState } from 'react'
import RepostDescription from './RepostDescription' import { useTranslation } from 'react-i18next'
import { FormattedTimestamp } from '../FormattedTimestamp'
import UserAvatar from '../UserAvatar' import UserAvatar from '../UserAvatar'
import Username from '../Username' import Username from '../Username'
import { FormattedTimestamp } from '../FormattedTimestamp' import RepostDescription from './RepostDescription'
import { useTranslation } from 'react-i18next'
export default function UnknownNoteCard({ export default function UnknownNoteCard({
event, event,
@@ -44,7 +44,7 @@ export default function UnknownNoteCard({
</div> </div>
</div> </div>
</div> </div>
<div className="flex flex-col gap-2 items-center text-muted-foreground font-medium mt-2"> <div className="flex flex-col gap-2 items-center text-muted-foreground font-medium my-4">
<div>{t('Cannot handle event of kind k', { k: event.kind })}</div> <div>{t('Cannot handle event of kind k', { k: event.kind })}</div>
<Button <Button
onClick={(e) => { onClick={(e) => {
@@ -53,7 +53,7 @@ export default function UnknownNoteCard({
setIsCopied(true) setIsCopied(true)
setTimeout(() => setIsCopied(false), 2000) setTimeout(() => setIsCopied(false), 2000)
}} }}
variant="ghost" variant="outline"
> >
{isCopied ? <Check /> : <Copy />} Copy event ID {isCopied ? <Check /> : <Copy />} Copy event ID
</Button> </Button>

View File

@@ -5,11 +5,12 @@ import {
DropdownMenuTrigger DropdownMenuTrigger
} from '@/components/ui/dropdown-menu' } from '@/components/ui/dropdown-menu'
import { getSharableEventId } from '@/lib/event' import { getSharableEventId } from '@/lib/event'
import { pubkeyToNpub } from '@/lib/pubkey'
import { useMuteList } from '@/providers/MuteListProvider' import { useMuteList } from '@/providers/MuteListProvider'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { BellOff, Code, Copy, Ellipsis } from 'lucide-react' import { Bell, BellOff, Code, Copy, Ellipsis } from 'lucide-react'
import { Event } from 'nostr-tools' import { Event } from 'nostr-tools'
import { useState } from 'react' import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import RawEventDialog from './RawEventDialog' import RawEventDialog from './RawEventDialog'
@@ -17,7 +18,8 @@ export default function NoteOptions({ event }: { event: Event }) {
const { t } = useTranslation() const { t } = useTranslation()
const { pubkey } = useNostr() const { pubkey } = useNostr()
const [isRawEventDialogOpen, setIsRawEventDialogOpen] = useState(false) const [isRawEventDialogOpen, setIsRawEventDialogOpen] = useState(false)
const { mutePubkey } = useMuteList() const { mutePubkey, unmutePubkey, mutePubkeys } = useMuteList()
const isMuted = useMemo(() => mutePubkeys.includes(event.pubkey), [mutePubkeys, event])
return ( return (
<div className="h-4" onClick={(e) => e.stopPropagation()}> <div className="h-4" onClick={(e) => e.stopPropagation()}>
@@ -30,22 +32,28 @@ export default function NoteOptions({ event }: { event: Event }) {
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent collisionPadding={8}> <DropdownMenuContent collisionPadding={8}>
<DropdownMenuItem <DropdownMenuItem
onClick={() => navigator.clipboard.writeText('nostr:' + getSharableEventId(event))} onClick={() => navigator.clipboard.writeText(getSharableEventId(event))}
> >
<Copy /> <Copy />
{t('copy embedded code')} {t('Copy event ID')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => navigator.clipboard.writeText(pubkeyToNpub(event.pubkey) ?? '')}
>
<Copy />
{t('Copy user ID')}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem onClick={() => setIsRawEventDialogOpen(true)}> <DropdownMenuItem onClick={() => setIsRawEventDialogOpen(true)}>
<Code /> <Code />
{t('raw event')} {t('View raw event')}
</DropdownMenuItem> </DropdownMenuItem>
{pubkey && ( {pubkey && (
<DropdownMenuItem <DropdownMenuItem
onClick={() => mutePubkey(event.pubkey)} onClick={() => (isMuted ? unmutePubkey(event.pubkey) : mutePubkey(event.pubkey))}
className="text-destructive focus:text-destructive" className="text-destructive focus:text-destructive"
> >
<BellOff /> {isMuted ? <Bell /> : <BellOff />}
{t('mute author')} {isMuted ? t('Unmute user') : t('Mute user')}
</DropdownMenuItem> </DropdownMenuItem>
)} )}
</DropdownMenuContent> </DropdownMenuContent>

View File

@@ -5,6 +5,7 @@ import { extractEmbeddedNotesFromContent, extractImagesFromContent } from '@/lib
import { toNote } from '@/lib/link' import { toNote } from '@/lib/link'
import { tagNameEquals } from '@/lib/tag' import { tagNameEquals } from '@/lib/tag'
import { useSecondaryPage } from '@/PageManager' import { useSecondaryPage } from '@/PageManager'
import { useMuteList } from '@/providers/MuteListProvider'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import client from '@/services/client.service' import client from '@/services/client.service'
import dayjs from 'dayjs' import dayjs from 'dayjs'
@@ -185,6 +186,10 @@ NotificationList.displayName = 'NotificationList'
export default NotificationList export default NotificationList
function NotificationItem({ notification }: { notification: Event }) { function NotificationItem({ notification }: { notification: Event }) {
const { mutePubkeys } = useMuteList()
if (mutePubkeys.includes(notification.pubkey)) {
return null
}
if (notification.kind === kinds.Reaction) { if (notification.kind === kinds.Reaction) {
return <ReactionNotification notification={notification} /> return <ReactionNotification notification={notification} />
} }

View File

@@ -1,5 +1,7 @@
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useMuteList } from '@/providers/MuteListProvider'
import { Event } from 'nostr-tools' import { Event } from 'nostr-tools'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import UserAvatar from '../UserAvatar' import UserAvatar from '../UserAvatar'
@@ -13,6 +15,9 @@ export default function ParentNotePreview({
onClick?: React.MouseEventHandler<HTMLDivElement> | undefined onClick?: React.MouseEventHandler<HTMLDivElement> | undefined
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
const { mutePubkeys } = useMuteList()
const isMuted = useMemo(() => mutePubkeys.includes(event.pubkey), [mutePubkeys, event])
return ( return (
<div <div
className={cn( className={cn(
@@ -23,7 +28,11 @@ export default function ParentNotePreview({
> >
<div className="shrink-0">{t('reply to')}</div> <div className="shrink-0">{t('reply to')}</div>
<UserAvatar className="shrink-0" userId={event.pubkey} size="tiny" /> <UserAvatar className="shrink-0" userId={event.pubkey} size="tiny" />
{isMuted ? (
<div className="truncate">{t('[muted]')}</div>
) : (
<div className="truncate">{event.content}</div> <div className="truncate">{event.content}</div>
)}
</div> </div>
) )
} }

View File

@@ -30,7 +30,7 @@ export default function ProfileOptions({ pubkey }: { pubkey: string }) {
onClick={() => navigator.clipboard.writeText('nostr:' + pubkeyToNpub(pubkey))} onClick={() => navigator.clipboard.writeText('nostr:' + pubkeyToNpub(pubkey))}
> >
<Copy /> <Copy />
{t('copy embedded code')} {t('Copy user ID')}
</DropdownMenuItem> </DropdownMenuItem>
{mutePubkeys.includes(pubkey) ? ( {mutePubkeys.includes(pubkey) ? (
<DropdownMenuItem <DropdownMenuItem
@@ -38,7 +38,7 @@ export default function ProfileOptions({ pubkey }: { pubkey: string }) {
className="text-destructive focus:text-destructive" className="text-destructive focus:text-destructive"
> >
<Bell /> <Bell />
{t('unmute user')} {t('Unmute user')}
</DropdownMenuItem> </DropdownMenuItem>
) : ( ) : (
<DropdownMenuItem <DropdownMenuItem
@@ -46,7 +46,7 @@ export default function ProfileOptions({ pubkey }: { pubkey: string }) {
className="text-destructive focus:text-destructive" className="text-destructive focus:text-destructive"
> >
<BellOff /> <BellOff />
{t('mute user')} {t('Mute user')}
</DropdownMenuItem> </DropdownMenuItem>
)} )}
</DropdownMenuContent> </DropdownMenuContent>

View File

@@ -1,4 +1,8 @@
import { Button } from '@/components/ui/button'
import { useMuteList } from '@/providers/MuteListProvider'
import { Event } from 'nostr-tools' import { Event } from 'nostr-tools'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Content from '../Content' import Content from '../Content'
import { FormattedTimestamp } from '../FormattedTimestamp' import { FormattedTimestamp } from '../FormattedTimestamp'
import NoteStats from '../NoteStats' import NoteStats from '../NoteStats'
@@ -17,6 +21,14 @@ export default function ReplyNote({
onClickParent?: (eventId: string) => void onClickParent?: (eventId: string) => void
highlight?: boolean highlight?: boolean
}) { }) {
const { t } = useTranslation()
const { mutePubkeys } = useMuteList()
const [showMuted, setShowMuted] = useState(false)
const show = useMemo(
() => showMuted || !mutePubkeys.includes(event.pubkey),
[showMuted, mutePubkeys, event]
)
return ( return (
<div <div
className={`flex space-x-2 items-start rounded-lg p-2 transition-colors duration-500 ${highlight ? 'bg-highlight/50' : ''}`} className={`flex space-x-2 items-start rounded-lg p-2 transition-colors duration-500 ${highlight ? 'bg-highlight/50' : ''}`}
@@ -43,8 +55,20 @@ export default function ReplyNote({
}} }}
/> />
)} )}
{show ? (
<>
<Content className="mt-1" event={event} size="small" /> <Content className="mt-1" event={event} size="small" />
<NoteStats className="mt-2" event={event} variant="reply" /> <NoteStats className="mt-2" event={event} variant="reply" />
</>
) : (
<Button
variant="outline"
className="text-muted-foreground font-medium mt-2"
onClick={() => setShowMuted(true)}
>
{t('Temporarily display this reply')}
</Button>
)}
</div> </div>
</div> </div>
) )

View File

@@ -41,8 +41,9 @@ export default {
'Your post has been published': 'Your post has been published', 'Your post has been published': 'Your post has been published',
Repost: 'Repost', Repost: 'Repost',
Quote: 'Quote', Quote: 'Quote',
'copy embedded code': 'copy embedded code', 'Copy event ID': 'Copy event ID',
'raw event': 'raw event', 'Copy user ID': 'Copy user ID',
'View raw event': 'View raw event',
Like: 'Like', Like: 'Like',
'switch to light theme': 'switch to light theme', 'switch to light theme': 'switch to light theme',
'switch to dark theme': 'switch to dark theme', 'switch to dark theme': 'switch to dark theme',
@@ -153,9 +154,8 @@ export default {
Mute: 'Mute', Mute: 'Mute',
Muted: 'Muted', Muted: 'Muted',
Unmute: 'Unmute', Unmute: 'Unmute',
'mute author': 'mute author', 'Mute user': 'Mute user',
'mute user': 'mute user', 'Unmute user': 'Unmute user',
'unmute user': 'unmute user',
'Append n relays': 'Append {{n}} relays', 'Append n relays': 'Append {{n}} relays',
Append: 'Append', Append: 'Append',
'Select relays to append': 'Select relays to append', 'Select relays to append': 'Select relays to append',

View File

@@ -41,8 +41,9 @@ export default {
'Your post has been published': '您的笔记已发布', 'Your post has been published': '您的笔记已发布',
Repost: '转发', Repost: '转发',
Quote: '引用', Quote: '引用',
'copy embedded code': '复制嵌入代码', 'Copy event ID': '复制事件 ID',
'raw event': '原始事件', 'Copy user ID': '复制用户 ID',
'View raw event': '查看原始事件',
Like: '点赞', Like: '点赞',
'switch to light theme': '切换到浅色主题', 'switch to light theme': '切换到浅色主题',
'switch to dark theme': '切换到深色主题', 'switch to dark theme': '切换到深色主题',
@@ -154,9 +155,8 @@ export default {
Mute: '屏蔽', Mute: '屏蔽',
Muted: '已屏蔽', Muted: '已屏蔽',
Unmute: '取消屏蔽', Unmute: '取消屏蔽',
'mute author': '屏蔽作者', 'Mute user': '屏蔽用户',
'mute user': '屏蔽用户', 'Unmute user': '取消屏蔽用户',
'unmute user': '取消屏蔽用户',
'Append n relays': '追加 {{n}} 个服务器', 'Append n relays': '追加 {{n}} 个服务器',
Append: '追加', Append: '追加',
'Select relays to append': '选择要追加的服务器', 'Select relays to append': '选择要追加的服务器',

View File

@@ -38,7 +38,7 @@ export function createReactionDraftEvent(event: Event): TDraftEvent {
export function createRepostDraftEvent(event: Event): TDraftEvent { export function createRepostDraftEvent(event: Event): TDraftEvent {
const isProtected = isProtectedEvent(event) const isProtected = isProtectedEvent(event)
const tags = [ const tags = [
['e', event.id, client.getEventHint(event.id), 'mentions', event.pubkey], ['e', event.id, client.getEventHint(event.id), '', event.pubkey],
['p', event.pubkey] ['p', event.pubkey]
] ]