You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
278 lines
9.7 KiB
278 lines
9.7 KiB
import { Skeleton } from '@/components/ui/skeleton' |
|
import { ExtendedKind, isNip71StyleVideoKind } from '@/constants' |
|
import { |
|
notificationReactionSummaryKey, |
|
useNotificationReactionDisplay |
|
} from '@/hooks/useNotificationReactionDisplay' |
|
import { isMentioningMutedUsers, isNip18RepostKind, isNip25ReactionKind } from '@/lib/event' |
|
import { |
|
DISCUSSION_DOWNVOTE_DISPLAY, |
|
DISCUSSION_UPVOTE_DISPLAY |
|
} from '@/lib/discussion-votes' |
|
import { getWebBookmarkArticleUrl } from '@/lib/rss-article' |
|
import { cn } from '@/lib/utils' |
|
import { useContentPolicyOptional } from '@/providers/ContentPolicyProvider' |
|
import { useMuteListOptional } from '@/contexts/mute-list-context' |
|
import { muteSetHas } from '@/lib/mute-set' |
|
import { mergeTranslatedNote, useNoteTranslation } from '@/lib/note-translation-display' |
|
import { Event, kinds } from 'nostr-tools' |
|
import { useMemo } from 'react' |
|
import { useTranslation } from 'react-i18next' |
|
import CommunityDefinitionPreview from './CommunityDefinitionPreview' |
|
import GroupMetadataPreview from './GroupMetadataPreview' |
|
import HighlightPreview from './HighlightPreview' |
|
import LiveEventPreview from './LiveEventPreview' |
|
import LongFormArticlePreview from './LongFormArticlePreview' |
|
import NormalContentPreview from './NormalContentPreview' |
|
import PictureNotePreview from './PictureNotePreview' |
|
import PollPreview from './PollPreview' |
|
import VideoNotePreview from './VideoNotePreview' |
|
import ZapPreview from './ZapPreview' |
|
import DiscussionNote from '../DiscussionNote' |
|
import ApplicationHandlerInfo from '../ApplicationHandlerInfo' |
|
import ApplicationHandlerRecommendation from '../ApplicationHandlerRecommendation' |
|
import FollowPackPreview from './FollowPackPreview' |
|
import ReactionEmojiDisplay from '../Note/ReactionEmojiDisplay' |
|
import NoteKindLabel from '../Note/NoteKindLabel' |
|
import Zap from '../Note/Zap' |
|
import GitRepublicEventCard from '../Note/GitRepublicEventCard' |
|
|
|
/** Inert event so hooks can run before `event` is defined. */ |
|
const CONTENT_PREVIEW_HOOK_PLACEHOLDER = { |
|
kind: kinds.ShortTextNote, |
|
id: '', |
|
pubkey: '', |
|
content: '', |
|
tags: [], |
|
created_at: 0, |
|
sig: '' |
|
} as Event |
|
|
|
const PARENT_REPLY_POLL_BLURB_MAX = 150 |
|
|
|
function parentReplyPollQuestionBlurb(content: string): string { |
|
const normalized = content.trim().replace(/\s+/g, ' ') |
|
if (normalized.length <= PARENT_REPLY_POLL_BLURB_MAX) return normalized |
|
return `${normalized.slice(0, PARENT_REPLY_POLL_BLURB_MAX)}…` |
|
} |
|
|
|
/** Keep spacing/margins on the outer wrapper; put line-clamp on the preview body so it still clamps text. */ |
|
function splitPreviewLayoutClasses(className?: string) { |
|
if (!className?.trim()) return { outer: undefined, body: undefined } |
|
const tokens = className.trim().split(/\s+/) |
|
const body: string[] = [] |
|
const outer: string[] = [] |
|
for (const tok of tokens) { |
|
if (tok.startsWith('line-clamp')) body.push(tok) |
|
else outer.push(tok) |
|
} |
|
return { |
|
outer: outer.length ? outer.join(' ') : undefined, |
|
body: body.length ? body.join(' ') : undefined |
|
} |
|
} |
|
|
|
export default function ContentPreview({ |
|
event, |
|
className, |
|
/** Inline parent lines (e.g. reply thread): zap receipts match compact thread styling. */ |
|
previewDensity, |
|
/** Reply-to-parent strip: polls show a short question snippet instead of full poll UI. */ |
|
forParentReplyBlurb = false |
|
}: { |
|
event?: Event |
|
className?: string |
|
previewDensity?: 'default' | 'compact' |
|
forParentReplyBlurb?: boolean |
|
}) { |
|
const { t } = useTranslation() |
|
const noteTr = useNoteTranslation(event?.id ?? '') |
|
const reactionDisplay = useNotificationReactionDisplay(event ?? CONTENT_PREVIEW_HOOK_PLACEHOLDER) |
|
const muteList = useMuteListOptional() |
|
const mutePubkeySet = muteList?.mutePubkeySet ?? new Set<string>() |
|
const contentPolicy = useContentPolicyOptional() |
|
const hideContentMentioningMutedUsers = contentPolicy?.hideContentMentioningMutedUsers ?? false |
|
const isMuted = useMemo( |
|
() => (event ? muteSetHas(mutePubkeySet, event.pubkey) : false), |
|
[mutePubkeySet, event] |
|
) |
|
const isMentioningMuted = useMemo( |
|
() => |
|
hideContentMentioningMutedUsers && event |
|
? isMentioningMutedUsers(event, mutePubkeySet) |
|
: false, |
|
[event, mutePubkeySet] |
|
) |
|
|
|
if (!event) { |
|
return <div className={cn('pointer-events-none', className)}>{`[${t('Note not found')}]`}</div> |
|
} |
|
|
|
if (isMuted) { |
|
return ( |
|
<div className={cn('pointer-events-none', className)}>[{t('This user has been muted')}]</div> |
|
) |
|
} |
|
|
|
if (isMentioningMuted) { |
|
return ( |
|
<div className={cn('pointer-events-none', className)}> |
|
[{t('This note mentions a user you muted')}] |
|
</div> |
|
) |
|
} |
|
|
|
const previewEvent = mergeTranslatedNote(event, noteTr) |
|
|
|
const { outer: previewOuter, body: previewBody } = splitPreviewLayoutClasses(className) |
|
|
|
const withKindRow = (node: React.ReactNode) => ( |
|
<div className={cn('flex min-w-0 flex-col gap-1', previewOuter)}> |
|
<NoteKindLabel kind={previewEvent.kind} event={previewEvent} size="small" /> |
|
<div className={cn('min-w-0', previewBody)}>{node}</div> |
|
</div> |
|
) |
|
|
|
if ( |
|
[ |
|
kinds.ShortTextNote, |
|
ExtendedKind.COMMENT, |
|
ExtendedKind.VOICE, |
|
ExtendedKind.VOICE_COMMENT, |
|
ExtendedKind.RELAY_REVIEW, |
|
ExtendedKind.PUBLIC_MESSAGE |
|
].includes(event.kind) |
|
) { |
|
return withKindRow(<NormalContentPreview event={previewEvent} />) |
|
} |
|
|
|
if (event.kind === ExtendedKind.DISCUSSION) { |
|
return ( |
|
<div className={cn('flex min-w-0 flex-col gap-1', previewOuter)}> |
|
<NoteKindLabel kind={previewEvent.kind} event={previewEvent} size="small" /> |
|
<div className={cn('min-w-0', previewBody)}> |
|
<DiscussionNote event={previewEvent} size="small" /> |
|
</div> |
|
</div> |
|
) |
|
} |
|
|
|
if (event.kind === kinds.Highlights) { |
|
return withKindRow(<HighlightPreview event={previewEvent} />) |
|
} |
|
|
|
if (event.kind === ExtendedKind.WEB_BOOKMARK) { |
|
const href = getWebBookmarkArticleUrl(previewEvent) |
|
const title = previewEvent.tags.find((t) => t[0] === 'title')?.[1]?.trim() |
|
const line = title?.trim() || href?.trim() || t('Web bookmark') |
|
return withKindRow(<div className={cn('min-w-0 truncate text-sm', previewBody)}>{line}</div>) |
|
} |
|
|
|
if (event.kind === ExtendedKind.POLL) { |
|
if (forParentReplyBlurb) { |
|
const snippet = parentReplyPollQuestionBlurb(previewEvent.content ?? '') |
|
return ( |
|
<div className={cn('pointer-events-none min-w-0 text-muted-foreground', previewOuter)}> |
|
<div className={cn('min-w-0 truncate', previewBody)}>{snippet || t('Poll')}</div> |
|
</div> |
|
) |
|
} |
|
return withKindRow(<PollPreview event={previewEvent} />) |
|
} |
|
|
|
if (event.kind === kinds.LongFormArticle) { |
|
return withKindRow(<LongFormArticlePreview event={previewEvent} />) |
|
} |
|
|
|
if (isNip71StyleVideoKind(event.kind)) { |
|
return withKindRow(<VideoNotePreview event={previewEvent} />) |
|
} |
|
|
|
if (event.kind === ExtendedKind.PICTURE) { |
|
return withKindRow(<PictureNotePreview event={previewEvent} />) |
|
} |
|
|
|
if (event.kind === ExtendedKind.GROUP_METADATA) { |
|
return withKindRow(<GroupMetadataPreview event={previewEvent} />) |
|
} |
|
|
|
if (event.kind === kinds.CommunityDefinition) { |
|
return withKindRow(<CommunityDefinitionPreview event={previewEvent} />) |
|
} |
|
|
|
if (event.kind === kinds.LiveEvent) { |
|
return withKindRow(<LiveEventPreview event={previewEvent} />) |
|
} |
|
|
|
if (event.kind === ExtendedKind.ZAP_REQUEST) { |
|
return withKindRow(<ZapPreview event={previewEvent} />) |
|
} |
|
|
|
if (event.kind === ExtendedKind.ZAP_RECEIPT || event.kind === kinds.Zap) { |
|
if (previewDensity === 'compact') { |
|
return ( |
|
<div className={cn('min-w-0', previewOuter)}> |
|
<Zap event={previewEvent} variant="compact" omitSenderHeading className={previewBody} /> |
|
</div> |
|
) |
|
} |
|
return withKindRow(<ZapPreview event={previewEvent} />) |
|
} |
|
|
|
if (event.kind === ExtendedKind.APPLICATION_HANDLER_INFO) { |
|
return withKindRow(<ApplicationHandlerInfo event={previewEvent} />) |
|
} |
|
|
|
if (event.kind === ExtendedKind.APPLICATION_HANDLER_RECOMMENDATION) { |
|
return withKindRow(<ApplicationHandlerRecommendation event={previewEvent} />) |
|
} |
|
|
|
if (event.kind === ExtendedKind.FOLLOW_PACK) { |
|
return withKindRow(<FollowPackPreview event={previewEvent} />) |
|
} |
|
|
|
if ( |
|
event.kind === ExtendedKind.GIT_REPO_ANNOUNCEMENT || |
|
event.kind === ExtendedKind.GIT_ISSUE || |
|
event.kind === ExtendedKind.GIT_RELEASE |
|
) { |
|
return withKindRow(<GitRepublicEventCard variant="compact" event={previewEvent} />) |
|
} |
|
|
|
if (isNip25ReactionKind(event.kind)) { |
|
return withKindRow( |
|
<div className="pointer-events-none flex items-center gap-1.5 text-sm text-muted-foreground"> |
|
{reactionDisplay.status === 'pending' ? ( |
|
<Skeleton className="size-4 shrink-0 rounded-sm" aria-hidden /> |
|
) : reactionDisplay.status === 'vote_up' ? ( |
|
<span className="text-base leading-none" aria-hidden> |
|
{DISCUSSION_UPVOTE_DISPLAY} |
|
</span> |
|
) : reactionDisplay.status === 'vote_down' ? ( |
|
<span className="text-base leading-none" aria-hidden> |
|
{DISCUSSION_DOWNVOTE_DISPLAY} |
|
</span> |
|
) : ( |
|
<ReactionEmojiDisplay event={previewEvent} maxRawLength={24} variant="compact" /> |
|
)} |
|
{t(notificationReactionSummaryKey(reactionDisplay))} |
|
</div> |
|
) |
|
} |
|
|
|
if (isNip18RepostKind(event.kind)) { |
|
return withKindRow( |
|
<div className="pointer-events-none text-sm text-muted-foreground">{t('Notification boost summary')}</div> |
|
) |
|
} |
|
|
|
if (event.kind === ExtendedKind.POLL_RESPONSE) { |
|
return withKindRow( |
|
<div className="pointer-events-none text-sm text-muted-foreground"> |
|
{t('Notification poll vote summary')} |
|
</div> |
|
) |
|
} |
|
|
|
return withKindRow(<div>[{t('Cannot handle event of kind k', { k: previewEvent.kind })}]</div>) |
|
}
|
|
|