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.
401 lines
15 KiB
401 lines
15 KiB
import storage from '@/services/local-storage.service' |
|
import { Drawer, DrawerContent, DrawerHeader, DrawerTitle } from '@/components/ui/drawer' |
|
import { |
|
DropdownMenu, |
|
DropdownMenuContent, |
|
DropdownMenuTrigger |
|
} from '@/components/ui/dropdown-menu' |
|
import { Skeleton } from '@/components/ui/skeleton' |
|
import { ExtendedKind } from '@/constants' |
|
import { useNoteStatsById } from '@/hooks/useNoteStatsById' |
|
import { useReplyUnderDiscussionRoot } from '@/hooks/useReplyUnderDiscussionRoot' |
|
import { createDeletionRequestDraftEvent, createReactionDraftEvent } from '@/lib/draft-event' |
|
import { |
|
DISCUSSION_DOWNVOTE_DISPLAY, |
|
DISCUSSION_UPVOTE_DISPLAY, |
|
DISCUSSION_VOTE_EMOJIS, |
|
discussionVoteMatches, |
|
isDiscussionDownvoteEmoji, |
|
isDiscussionUpvoteEmoji, |
|
isDiscussionVoteEmoji |
|
} from '@/lib/discussion-votes' |
|
import { useNoteStatsRelayHints } from '@/hooks/useNoteStatsRelayHints' |
|
import { useSignGatedControl } from '@/hooks/useSignGatedControl' |
|
import { useNostr } from '@/providers/NostrProvider' |
|
import { useScreenSize } from '@/providers/ScreenSizeProvider' |
|
import { eventService } from '@/services/client.service' |
|
import noteStatsService from '@/services/note-stats.service' |
|
import { |
|
displayListCountWithArchives, |
|
noteStatsHasResolvableCounts, |
|
type TNoteStats |
|
} from '@/services/note-stats.service' |
|
import { TEmoji } from '@/types' |
|
import { SmilePlus } from 'lucide-react' |
|
import { Event } from 'nostr-tools' |
|
import { useMemo, useState } from 'react' |
|
import logger from '@/lib/logger' |
|
import { useTranslation } from 'react-i18next' |
|
import Emoji, { EMOJI_IMG_INLINE_CLASS } from '../Emoji' |
|
import EmojiPicker, { EMOJI_PICKER_REACTIONS } from '../EmojiPicker' |
|
import { DiscussionVoteCountHover, ReactionCountHover } from './NoteStatsCountHover' |
|
import { |
|
type RelayStatus, |
|
showPublishingError, |
|
showPublishingFeedback, |
|
showSimplePublishSuccess |
|
} from '@/lib/publishing-feedback' |
|
import { LoginRequiredError } from '@/lib/nostr-errors' |
|
import { cn } from '@/lib/utils' |
|
import { WEB_EXTERNAL_REACTION_PUBLISHED_EVENT } from '@/lib/rss-web-feed' |
|
|
|
type LikeButtonProps = { |
|
event: Event |
|
hideCount?: boolean |
|
noteStats?: Partial<TNoteStats> |
|
isReplyToDiscussion?: boolean |
|
/** When true, never show the user's last reaction emoji in the trigger (icon + count only). */ |
|
useIconOnlyLikeTrigger?: boolean |
|
} |
|
|
|
export function LikeButtonWithStats({ |
|
event, |
|
hideCount = false, |
|
noteStats, |
|
isReplyToDiscussion: isReplyToDiscussionProp, |
|
useIconOnlyLikeTrigger = false |
|
}: LikeButtonProps) { |
|
const { t } = useTranslation() |
|
const { isSmallScreen } = useScreenSize() |
|
const { pubkey, publish, checkLogin } = useNostr() |
|
const { canSignEvents, signControlProps } = useSignGatedControl() |
|
const { relays: statsRelays } = useNoteStatsRelayHints() |
|
const [liking, setLiking] = useState(false) |
|
const [isEmojiReactionsOpen, setIsEmojiReactionsOpen] = useState(false) |
|
const isDiscussion = event.kind === ExtendedKind.DISCUSSION |
|
const isReplyToDiscussion = isReplyToDiscussionProp ?? false |
|
const showDiscussionVotes = isDiscussion || isReplyToDiscussion |
|
|
|
const statsLoaded = noteStatsHasResolvableCounts(noteStats) |
|
|
|
const { myLastEmoji, likeCount, upVoteCount, downVoteCount } = useMemo(() => { |
|
const stats = noteStats || {} |
|
const likes = stats.likes |
|
|
|
const myLike = likes?.find((like) => { |
|
if (like.pubkey !== pubkey) return false |
|
if (showDiscussionVotes) return isDiscussionVoteEmoji(like.emoji) |
|
return true |
|
}) |
|
|
|
let upVoteCount = 0 |
|
let downVoteCount = 0 |
|
if (showDiscussionVotes) { |
|
upVoteCount = likes?.filter((like) => isDiscussionUpvoteEmoji(like.emoji)).length || 0 |
|
downVoteCount = likes?.filter((like) => isDiscussionDownvoteEmoji(like.emoji)).length || 0 |
|
} |
|
|
|
return { |
|
myLastEmoji: myLike?.emoji, |
|
likeCount: showDiscussionVotes |
|
? likes?.length |
|
: displayListCountWithArchives(likes?.length, stats.archivesInteractions, 'reactions'), |
|
upVoteCount, |
|
downVoteCount |
|
} |
|
}, [noteStats, pubkey, showDiscussionVotes]) |
|
|
|
/** Same idea as {@link ReplyButton}: merged likes (thread fetch / publish) can exist before snapshot sets `updatedAt`. */ |
|
const showLikeCount = !hideCount && (statsLoaded || (likeCount ?? 0) > 0) |
|
|
|
const like = async (emoji: string | TEmoji) => { |
|
checkLogin(async () => { |
|
if (liking || !canSignEvents) return |
|
|
|
setLiking(true) |
|
const timer = setTimeout(() => setLiking(false), 10_000) |
|
|
|
try { |
|
if (!noteStats?.updatedAt) { |
|
await noteStatsService.fetchNoteStats(event, pubkey, statsRelays, { foreground: true }) |
|
} |
|
|
|
const emojiString = typeof emoji === 'string' ? emoji : emoji.shortcode |
|
const myLastEmojiString = |
|
typeof myLastEmoji === 'string' |
|
? myLastEmoji |
|
: typeof myLastEmoji === 'object' |
|
? myLastEmoji.shortcode |
|
: undefined |
|
const isTogglingOff = |
|
pubkey && |
|
(showDiscussionVotes |
|
? discussionVoteMatches(myLastEmoji, emoji) |
|
: myLastEmojiString === emojiString) |
|
|
|
logger.debug('Like toggle check', { |
|
myLastEmoji, |
|
myLastEmojiString, |
|
emojiString, |
|
isTogglingOff, |
|
myLikes: noteStats?.likes?.filter(like => like.pubkey === pubkey) |
|
}) |
|
|
|
if (isTogglingOff) { |
|
// User wants to toggle off - find their previous reaction and delete it |
|
const myReaction = noteStats?.likes?.find((like) => { |
|
if (!pubkey || like.pubkey !== pubkey) return false |
|
if (showDiscussionVotes) return discussionVoteMatches(like.emoji, emoji) |
|
const likeEmojiString = typeof like.emoji === 'string' ? like.emoji : like.emoji.shortcode |
|
return likeEmojiString === emojiString |
|
}) |
|
|
|
if (myReaction) { |
|
// Optimistically update the UI immediately |
|
noteStatsService.removeLike(event.id, myReaction.id) |
|
|
|
// Fetch the actual reaction event |
|
const reactionEvent = await eventService.fetchEvent(myReaction.id) |
|
if (reactionEvent) { |
|
// Create and publish a deletion request (kind 5) |
|
const deletionRequest = createDeletionRequestDraftEvent(reactionEvent) |
|
const deletedEvent = await publish(deletionRequest, { |
|
addClientTag: storage.getAddClientTag() |
|
}) |
|
|
|
// Show publishing feedback |
|
if ((deletedEvent as any)?.relayStatuses) { |
|
showPublishingFeedback({ |
|
success: true, |
|
relayStatuses: (deletedEvent as any).relayStatuses, |
|
successCount: (deletedEvent as any).relayStatuses.filter((s: any) => s.success).length, |
|
totalCount: (deletedEvent as any).relayStatuses.length |
|
}, { |
|
message: t('Reaction removed'), |
|
duration: 4000 |
|
}) |
|
} else { |
|
showSimplePublishSuccess(t('Reaction removed')) |
|
} |
|
if ( |
|
event.kind === ExtendedKind.RSS_THREAD_ROOT && |
|
reactionEvent?.kind === ExtendedKind.EXTERNAL_REACTION |
|
) { |
|
window.dispatchEvent(new CustomEvent(WEB_EXTERNAL_REACTION_PUBLISHED_EVENT)) |
|
} |
|
} |
|
} |
|
} else { |
|
// User is adding a new reaction |
|
const reaction = createReactionDraftEvent(event, emoji) |
|
const evt = await publish(reaction, { addClientTag: storage.getAddClientTag() }) |
|
|
|
// Show publishing feedback |
|
if ((evt as any)?.relayStatuses) { |
|
showPublishingFeedback({ |
|
success: true, |
|
relayStatuses: (evt as any).relayStatuses, |
|
successCount: (evt as any).relayStatuses.filter((s: any) => s.success).length, |
|
totalCount: (evt as any).relayStatuses.length |
|
}, { |
|
message: t('Reaction published'), |
|
duration: 4000 |
|
}) |
|
} else { |
|
showSimplePublishSuccess(t('Reaction published')) |
|
} |
|
|
|
noteStatsService.updateNoteStatsByEvents([evt], undefined, { |
|
interactionTargetNoteId: event.id |
|
}) |
|
if (event.kind === ExtendedKind.RSS_THREAD_ROOT && evt.kind === ExtendedKind.EXTERNAL_REACTION) { |
|
window.dispatchEvent(new CustomEvent(WEB_EXTERNAL_REACTION_PUBLISHED_EVENT)) |
|
} |
|
} |
|
} catch (error) { |
|
if (error instanceof LoginRequiredError) { |
|
return |
|
} |
|
logger.error('Like failed', { error, eventId: event.id }) |
|
if (error instanceof AggregateError && (error as AggregateError & { relayStatuses?: RelayStatus[] }).relayStatuses) { |
|
const relayStatuses = (error as AggregateError & { relayStatuses: RelayStatus[] }).relayStatuses |
|
const successCount = relayStatuses.filter((s) => s.success).length |
|
showPublishingFeedback( |
|
{ |
|
success: successCount > 0, |
|
relayStatuses, |
|
successCount, |
|
totalCount: relayStatuses.length |
|
}, |
|
{ |
|
message: |
|
successCount > 0 ? t('Reaction published to some relays') : t('Failed to publish reaction'), |
|
duration: 6000 |
|
} |
|
) |
|
} else { |
|
showPublishingError(error instanceof Error ? error.message : t('Failed to publish reaction')) |
|
} |
|
} finally { |
|
setLiking(false) |
|
clearTimeout(timer) |
|
} |
|
}) |
|
} |
|
|
|
const openReactionPicker = () => { |
|
if (!canSignEvents) return |
|
if (myLastEmoji && !isEmojiReactionsOpen) { |
|
like(myLastEmoji) |
|
return |
|
} |
|
setIsEmojiReactionsOpen(true) |
|
} |
|
|
|
const likeIconButton = ( |
|
<button |
|
type="button" |
|
className="flex h-full min-w-0 items-center gap-1.5 px-2 text-muted-foreground enabled:hover:text-primary touch-manipulation" |
|
{...signControlProps({ title: t('Like'), disabled: liking })} |
|
onClick={openReactionPicker} |
|
> |
|
{liking ? ( |
|
<Skeleton className="size-5 shrink-0 rounded-full" aria-hidden /> |
|
) : myLastEmoji && !useIconOnlyLikeTrigger ? ( |
|
<Emoji emoji={myLastEmoji} classNames={{ img: EMOJI_IMG_INLINE_CLASS }} /> |
|
) : ( |
|
<SmilePlus /> |
|
)} |
|
</button> |
|
) |
|
|
|
const likeCountLabel = showLikeCount ? ( |
|
<ReactionCountHover noteStats={noteStats}> |
|
<div className="pr-1 text-sm tabular-nums"> |
|
{(likeCount ?? 0) >= 100 ? '99+' : String(likeCount ?? 0)} |
|
</div> |
|
</ReactionCountHover> |
|
) : ( |
|
<span className="pr-1" aria-hidden /> |
|
) |
|
|
|
// Discussions (kind 11) and kind 1111 under a discussion: only +/- vote reactions |
|
if (showDiscussionVotes) { |
|
return ( |
|
<div className="flex max-w-full items-center gap-0.5 sm:gap-1"> |
|
{DISCUSSION_VOTE_EMOJIS.map((emoji, index) => { |
|
const isSelected = |
|
index === 0 ? isDiscussionUpvoteEmoji(myLastEmoji) : isDiscussionDownvoteEmoji(myLastEmoji) |
|
const count = index === 0 ? upVoteCount : downVoteCount |
|
const arrow = index === 0 ? DISCUSSION_UPVOTE_DISPLAY : DISCUSSION_DOWNVOTE_DISPLAY |
|
return ( |
|
<div |
|
key={emoji} |
|
className={cn( |
|
'flex h-full shrink-0 items-center rounded', |
|
isSelected ? 'bg-muted text-primary' : 'text-muted-foreground' |
|
)} |
|
> |
|
<button |
|
type="button" |
|
className="flex h-full shrink-0 items-center px-2 sm:px-2.5 enabled:hover:text-primary touch-manipulation" |
|
{...signControlProps({ |
|
title: emoji === '+' ? t('Upvote') : t('Downvote'), |
|
disabled: liking |
|
})} |
|
onClick={() => { |
|
like(emoji) |
|
}} |
|
> |
|
{liking ? ( |
|
<Skeleton className="size-5 shrink-0 rounded-full" aria-hidden /> |
|
) : ( |
|
<span className="text-base leading-none" aria-hidden> |
|
{arrow} |
|
</span> |
|
)} |
|
</button> |
|
{!hideCount && (noteStats?.updatedAt != null || count > 0) ? ( |
|
<DiscussionVoteCountHover noteStats={noteStats} vote={index === 0 ? 'up' : 'down'}> |
|
<div className="pr-1 text-sm tabular-nums sm:pr-2"> |
|
{count >= 100 ? '99+' : count} |
|
</div> |
|
</DiscussionVoteCountHover> |
|
) : null} |
|
</div> |
|
) |
|
})} |
|
</div> |
|
) |
|
} |
|
|
|
const likeEmojiPicker = ( |
|
<EmojiPicker |
|
reactionsDefaultOpen |
|
reactions={[...EMOJI_PICKER_REACTIONS]} |
|
onEmojiClick={(emoji, e) => { |
|
e.stopPropagation() |
|
setIsEmojiReactionsOpen(false) |
|
if (!emoji) return |
|
like(emoji) |
|
}} |
|
/> |
|
) |
|
|
|
if (isSmallScreen) { |
|
return ( |
|
<> |
|
<div className="flex h-full min-w-0 items-center"> |
|
{likeIconButton} |
|
{likeCountLabel} |
|
</div> |
|
<Drawer handleOnly open={isEmojiReactionsOpen} onOpenChange={setIsEmojiReactionsOpen}> |
|
<DrawerContent |
|
dragHandle="vaul" |
|
className="max-h-[min(88dvh,calc(100dvh-5rem))]" |
|
onPointerDownOutside={(e) => { |
|
const t = e.target as HTMLElement | null |
|
if (t?.closest?.('[data-vaul-overlay]')) return |
|
e.preventDefault() |
|
}} |
|
> |
|
<DrawerHeader className="sr-only"> |
|
<DrawerTitle>React</DrawerTitle> |
|
</DrawerHeader> |
|
<div className="flex min-h-0 w-full max-h-[min(72dvh,calc(100dvh-6rem))] flex-col overflow-hidden px-1 pb-1"> |
|
{isEmojiReactionsOpen ? likeEmojiPicker : null} |
|
</div> |
|
</DrawerContent> |
|
</Drawer> |
|
</> |
|
) |
|
} |
|
|
|
return ( |
|
<div className="flex h-full min-w-0 items-center"> |
|
<DropdownMenu open={isEmojiReactionsOpen} onOpenChange={setIsEmojiReactionsOpen}> |
|
<DropdownMenuTrigger asChild>{likeIconButton}</DropdownMenuTrigger> |
|
<DropdownMenuContent |
|
side="top" |
|
className="p-0 w-[min(100vw-1rem,350px)] max-w-[calc(100vw-1rem)] overflow-hidden" |
|
> |
|
{likeEmojiPicker} |
|
</DropdownMenuContent> |
|
</DropdownMenu> |
|
{likeCountLabel} |
|
</div> |
|
) |
|
} |
|
|
|
export default function LikeButton({ event, hideCount = false }: LikeButtonProps) { |
|
const noteStats = useNoteStatsById(event.id) |
|
const isReplyToDiscussion = useReplyUnderDiscussionRoot(event) |
|
return ( |
|
<LikeButtonWithStats |
|
event={event} |
|
hideCount={hideCount} |
|
noteStats={noteStats} |
|
isReplyToDiscussion={isReplyToDiscussion} |
|
/> |
|
) |
|
}
|
|
|