15 changed files with 382 additions and 50 deletions
@ -0,0 +1,38 @@ |
|||||||
|
import { useScreenSize } from '@/providers/ScreenSizeProvider' |
||||||
|
import { useTheme } from '@/providers/ThemeProvider' |
||||||
|
import EmojiPickerReact, { |
||||||
|
EmojiStyle, |
||||||
|
SkinTonePickerLocation, |
||||||
|
SuggestionMode, |
||||||
|
Theme |
||||||
|
} from 'emoji-picker-react' |
||||||
|
import { MouseDownEvent } from 'emoji-picker-react/dist/config/config' |
||||||
|
|
||||||
|
export default function EmojiPicker({ onEmojiClick }: { onEmojiClick: MouseDownEvent }) { |
||||||
|
const { themeSetting } = useTheme() |
||||||
|
const { isSmallScreen } = useScreenSize() |
||||||
|
|
||||||
|
return ( |
||||||
|
<EmojiPickerReact |
||||||
|
theme={ |
||||||
|
themeSetting === 'system' ? Theme.AUTO : themeSetting === 'dark' ? Theme.DARK : Theme.LIGHT |
||||||
|
} |
||||||
|
width={isSmallScreen ? '100%' : 350} |
||||||
|
autoFocusSearch={false} |
||||||
|
searchDisabled |
||||||
|
emojiStyle={EmojiStyle.NATIVE} |
||||||
|
skinTonePickerLocation={SkinTonePickerLocation.PREVIEW} |
||||||
|
style={ |
||||||
|
{ |
||||||
|
'--epr-bg-color': 'hsl(var(--background))', |
||||||
|
'--epr-category-label-bg-color': 'hsl(var(--background))', |
||||||
|
'--epr-text-color': 'hsl(var(--foreground))', |
||||||
|
'--epr-hover-bg-color': 'hsl(var(--muted) / 0.5)', |
||||||
|
'--epr-picker-border-color': 'transparent' |
||||||
|
} as React.CSSProperties |
||||||
|
} |
||||||
|
suggestedEmojisMode={SuggestionMode.FREQUENT} |
||||||
|
onEmojiClick={onEmojiClick} |
||||||
|
/> |
||||||
|
) |
||||||
|
} |
||||||
@ -0,0 +1,78 @@ |
|||||||
|
import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area' |
||||||
|
import { createReactionDraftEvent } from '@/lib/draft-event' |
||||||
|
import { cn } from '@/lib/utils' |
||||||
|
import { useNostr } from '@/providers/NostrProvider' |
||||||
|
import { useNoteStats } from '@/providers/NoteStatsProvider' |
||||||
|
import { TEmoji } from '@/types' |
||||||
|
import { Loader } from 'lucide-react' |
||||||
|
import { Event } from 'nostr-tools' |
||||||
|
import { useMemo, useState } from 'react' |
||||||
|
import Emoji from '../Emoji' |
||||||
|
|
||||||
|
export default function Likes({ event }: { event: Event }) { |
||||||
|
const { pubkey, checkLogin, publish } = useNostr() |
||||||
|
const { noteStatsMap, updateNoteStatsByEvents } = useNoteStats() |
||||||
|
const [liking, setLiking] = useState<string | null>(null) |
||||||
|
const likes = useMemo(() => { |
||||||
|
const _likes = noteStatsMap.get(event.id)?.likes |
||||||
|
if (!_likes) return [] |
||||||
|
|
||||||
|
const stats = new Map<string, { key: string; emoji: TEmoji | string; pubkeys: Set<string> }>() |
||||||
|
_likes.forEach((item) => { |
||||||
|
const key = typeof item.emoji === 'string' ? item.emoji : item.emoji.url |
||||||
|
if (!stats.has(key)) { |
||||||
|
stats.set(key, { key, pubkeys: new Set(), emoji: item.emoji }) |
||||||
|
} |
||||||
|
stats.get(key)?.pubkeys.add(item.pubkey) |
||||||
|
}) |
||||||
|
return Array.from(stats.values()).sort((a, b) => b.pubkeys.size - a.pubkeys.size) |
||||||
|
}, [noteStatsMap, event]) |
||||||
|
|
||||||
|
if (!likes.length) return null |
||||||
|
|
||||||
|
const like = async (key: string, emoji: TEmoji | string) => { |
||||||
|
checkLogin(async () => { |
||||||
|
if (liking || !pubkey) return |
||||||
|
|
||||||
|
setLiking(key) |
||||||
|
const timer = setTimeout(() => setLiking((prev) => (prev === key ? null : prev)), 5000) |
||||||
|
|
||||||
|
try { |
||||||
|
const reaction = createReactionDraftEvent(event, emoji) |
||||||
|
const evt = await publish(reaction) |
||||||
|
updateNoteStatsByEvents([evt]) |
||||||
|
} catch (error) { |
||||||
|
console.error('like failed', error) |
||||||
|
} finally { |
||||||
|
setLiking(null) |
||||||
|
clearTimeout(timer) |
||||||
|
} |
||||||
|
}) |
||||||
|
} |
||||||
|
|
||||||
|
return ( |
||||||
|
<ScrollArea className="pb-2 mb-1"> |
||||||
|
<div className="flex gap-1"> |
||||||
|
{likes.map(({ key, emoji, pubkeys }) => ( |
||||||
|
<div |
||||||
|
key={key} |
||||||
|
className={cn( |
||||||
|
'flex h-7 w-fit gap-2 px-2 rounded-full items-center border shrink-0', |
||||||
|
pubkey && pubkeys.has(pubkey) |
||||||
|
? 'border-primary bg-primary/20 text-foreground cursor-not-allowed' |
||||||
|
: 'transition-colors bg-muted/80 text-muted-foreground cursor-pointer hover:bg-primary/40 hover:border-primary hover:text-foreground' |
||||||
|
)} |
||||||
|
onClick={(e) => { |
||||||
|
e.stopPropagation() |
||||||
|
like(key, emoji) |
||||||
|
}} |
||||||
|
> |
||||||
|
{liking === key ? <Loader className="animate-spin size-5" /> : <Emoji emoji={emoji} />} |
||||||
|
<div className="text-sm">{pubkeys.size}</div> |
||||||
|
</div> |
||||||
|
))} |
||||||
|
</div> |
||||||
|
<ScrollBar orientation="horizontal" /> |
||||||
|
</ScrollArea> |
||||||
|
) |
||||||
|
} |
||||||
Loading…
Reference in new issue