25 changed files with 483 additions and 98 deletions
@ -1,17 +1,26 @@ |
|||||||
|
import { useNostr } from '@renderer/providers/NostrProvider' |
||||||
import { useNoteStats } from '@renderer/providers/NoteStatsProvider' |
import { useNoteStats } from '@renderer/providers/NoteStatsProvider' |
||||||
import { MessageCircle } from 'lucide-react' |
import { MessageCircle } from 'lucide-react' |
||||||
import { Event } from 'nostr-tools' |
import { Event } from 'nostr-tools' |
||||||
import { useMemo } from 'react' |
import { useMemo } from 'react' |
||||||
|
import PostDialog from '../PostDialog' |
||||||
import { formatCount } from './utils' |
import { formatCount } from './utils' |
||||||
|
|
||||||
export default function ReplyButton({ event }: { event: Event }) { |
export default function ReplyButton({ event }: { event: Event }) { |
||||||
const { noteStatsMap } = useNoteStats() |
const { noteStatsMap } = useNoteStats() |
||||||
|
const { pubkey } = useNostr() |
||||||
const { replyCount } = useMemo(() => noteStatsMap.get(event.id) ?? {}, [noteStatsMap, event.id]) |
const { replyCount } = useMemo(() => noteStatsMap.get(event.id) ?? {}, [noteStatsMap, event.id]) |
||||||
|
|
||||||
return ( |
return ( |
||||||
<div className="flex gap-1 items-center text-muted-foreground"> |
<PostDialog parentEvent={event}> |
||||||
|
<button |
||||||
|
className="flex gap-1 items-center text-muted-foreground enabled:hover:text-blue-400" |
||||||
|
disabled={!pubkey} |
||||||
|
onClick={(e) => e.stopPropagation()} |
||||||
|
> |
||||||
<MessageCircle size={16} /> |
<MessageCircle size={16} /> |
||||||
<div className="text-xs">{formatCount(replyCount)}</div> |
<div className="text-xs">{formatCount(replyCount)}</div> |
||||||
</div> |
</button> |
||||||
|
</PostDialog> |
||||||
) |
) |
||||||
} |
} |
||||||
|
|||||||
@ -0,0 +1,47 @@ |
|||||||
|
import { Button } from '@renderer/components/ui/button' |
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover' |
||||||
|
import { extractMentions } from '@renderer/lib/event' |
||||||
|
import { useEffect, useState } from 'react' |
||||||
|
import UserAvatar from '../UserAvatar' |
||||||
|
import Username from '../Username' |
||||||
|
import { Event } from 'nostr-tools' |
||||||
|
|
||||||
|
export default function Mentions({ |
||||||
|
content, |
||||||
|
parentEvent |
||||||
|
}: { |
||||||
|
content: string |
||||||
|
parentEvent?: Event |
||||||
|
}) { |
||||||
|
const [pubkeys, setPubkeys] = useState<string[]>([]) |
||||||
|
|
||||||
|
useEffect(() => { |
||||||
|
extractMentions(content, parentEvent).then(({ pubkeys }) => setPubkeys(pubkeys)) |
||||||
|
}, [content]) |
||||||
|
|
||||||
|
return ( |
||||||
|
<Popover> |
||||||
|
<PopoverTrigger asChild> |
||||||
|
<Button |
||||||
|
className="px-3" |
||||||
|
variant="ghost" |
||||||
|
disabled={pubkeys.length === 0} |
||||||
|
onClick={(e) => e.stopPropagation()} |
||||||
|
> |
||||||
|
Mentions {pubkeys.length > 0 && `(${pubkeys.length})`} |
||||||
|
</Button> |
||||||
|
</PopoverTrigger> |
||||||
|
<PopoverContent className="w-48"> |
||||||
|
<div className="space-y-2"> |
||||||
|
<div className="text-sm font-semibold">Mentions:</div> |
||||||
|
{pubkeys.map((pubkey, index) => ( |
||||||
|
<div key={`${pubkey}-${index}`} className="flex gap-1 items-center"> |
||||||
|
<UserAvatar userId={pubkey} size="small" /> |
||||||
|
<Username userId={pubkey} className="font-semibold text-sm truncate" /> |
||||||
|
</div> |
||||||
|
))} |
||||||
|
</div> |
||||||
|
</PopoverContent> |
||||||
|
</Popover> |
||||||
|
) |
||||||
|
} |
||||||
@ -0,0 +1,21 @@ |
|||||||
|
import { Card } from '@renderer/components/ui/card' |
||||||
|
import dayjs from 'dayjs' |
||||||
|
import Content from '../Content' |
||||||
|
|
||||||
|
export default function Preview({ content }: { content: string }) { |
||||||
|
return ( |
||||||
|
<Card className="p-3"> |
||||||
|
<Content |
||||||
|
event={{ |
||||||
|
content, |
||||||
|
kind: 1, |
||||||
|
tags: [], |
||||||
|
created_at: dayjs().unix(), |
||||||
|
id: '', |
||||||
|
pubkey: '', |
||||||
|
sig: '' |
||||||
|
}} |
||||||
|
/> |
||||||
|
</Card> |
||||||
|
) |
||||||
|
} |
||||||
@ -0,0 +1,131 @@ |
|||||||
|
import { Button } from '@renderer/components/ui/button' |
||||||
|
import { |
||||||
|
Dialog, |
||||||
|
DialogContent, |
||||||
|
DialogFooter, |
||||||
|
DialogHeader, |
||||||
|
DialogTitle, |
||||||
|
DialogTrigger |
||||||
|
} from '@renderer/components/ui/dialog' |
||||||
|
import { ScrollArea } from '@renderer/components/ui/scroll-area' |
||||||
|
import { Textarea } from '@renderer/components/ui/textarea' |
||||||
|
import { useToast } from '@renderer/hooks/use-toast' |
||||||
|
import { createShortTextNoteDraftEvent } from '@renderer/lib/draft-event' |
||||||
|
import { useNostr } from '@renderer/providers/NostrProvider' |
||||||
|
import client from '@renderer/services/client.service' |
||||||
|
import { LoaderCircle } from 'lucide-react' |
||||||
|
import { Event } from 'nostr-tools' |
||||||
|
import { useState } from 'react' |
||||||
|
import UserAvatar from '../UserAvatar' |
||||||
|
import Mentions from './Metions' |
||||||
|
import Preview from './Preview' |
||||||
|
|
||||||
|
export default function PostDialog({ |
||||||
|
children, |
||||||
|
parentEvent |
||||||
|
}: { |
||||||
|
children: React.ReactNode |
||||||
|
parentEvent?: Event |
||||||
|
}) { |
||||||
|
const { toast } = useToast() |
||||||
|
const { pubkey, publish } = useNostr() |
||||||
|
const [open, setOpen] = useState(false) |
||||||
|
const [content, setContent] = useState('') |
||||||
|
const [posting, setPosting] = useState(false) |
||||||
|
|
||||||
|
const handleTextareaChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => { |
||||||
|
setContent(e.target.value) |
||||||
|
} |
||||||
|
|
||||||
|
const post = async (e: React.MouseEvent) => { |
||||||
|
e.stopPropagation() |
||||||
|
if (!content || !pubkey || posting) { |
||||||
|
setOpen(false) |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
setPosting(true) |
||||||
|
try { |
||||||
|
const additionalRelayUrls: string[] = [] |
||||||
|
if (parentEvent) { |
||||||
|
const relayList = await client.fetchRelayList(parentEvent.pubkey) |
||||||
|
additionalRelayUrls.push(...relayList.read.slice(0, 5)) |
||||||
|
} |
||||||
|
const draftEvent = await createShortTextNoteDraftEvent(content, parentEvent) |
||||||
|
await publish(draftEvent, additionalRelayUrls) |
||||||
|
setContent('') |
||||||
|
setOpen(false) |
||||||
|
} catch (error) { |
||||||
|
if (error instanceof AggregateError) { |
||||||
|
error.errors.forEach((e) => |
||||||
|
toast({ |
||||||
|
variant: 'destructive', |
||||||
|
title: 'Failed to post', |
||||||
|
description: e.message |
||||||
|
}) |
||||||
|
) |
||||||
|
} else if (error instanceof Error) { |
||||||
|
toast({ |
||||||
|
variant: 'destructive', |
||||||
|
title: 'Failed to post', |
||||||
|
description: error.message |
||||||
|
}) |
||||||
|
} |
||||||
|
console.error(error) |
||||||
|
return |
||||||
|
} finally { |
||||||
|
setPosting(false) |
||||||
|
} |
||||||
|
toast({ |
||||||
|
title: 'Post successful', |
||||||
|
description: 'Your post has been published' |
||||||
|
}) |
||||||
|
} |
||||||
|
|
||||||
|
return ( |
||||||
|
<Dialog open={open} onOpenChange={setOpen}> |
||||||
|
<DialogTrigger asChild>{children}</DialogTrigger> |
||||||
|
<DialogContent className="p-0" withoutClose> |
||||||
|
<ScrollArea className="px-4 h-full max-h-screen"> |
||||||
|
<div className="space-y-4 px-2 py-6"> |
||||||
|
<DialogHeader> |
||||||
|
<DialogTitle> |
||||||
|
{parentEvent ? ( |
||||||
|
<div className="flex gap-2 items-center max-w-full"> |
||||||
|
<div className="shrink-0">Reply to</div> |
||||||
|
<UserAvatar userId={parentEvent.pubkey} size="tiny" /> |
||||||
|
<div className="truncate">{parentEvent.content}</div> |
||||||
|
</div> |
||||||
|
) : ( |
||||||
|
'New post' |
||||||
|
)} |
||||||
|
</DialogTitle> |
||||||
|
</DialogHeader> |
||||||
|
<Textarea |
||||||
|
onChange={handleTextareaChange} |
||||||
|
value={content} |
||||||
|
placeholder="Write something..." |
||||||
|
/> |
||||||
|
{content && <Preview content={content} />} |
||||||
|
<DialogFooter className="items-center"> |
||||||
|
<Mentions content={content} parentEvent={parentEvent} /> |
||||||
|
<Button |
||||||
|
variant="secondary" |
||||||
|
onClick={(e) => { |
||||||
|
e.stopPropagation() |
||||||
|
setOpen(false) |
||||||
|
}} |
||||||
|
> |
||||||
|
Cancel |
||||||
|
</Button> |
||||||
|
<Button type="submit" disabled={!pubkey || posting} onClick={post}> |
||||||
|
{posting && <LoaderCircle className="animate-spin" />} |
||||||
|
Post |
||||||
|
</Button> |
||||||
|
</DialogFooter> |
||||||
|
</div> |
||||||
|
</ScrollArea> |
||||||
|
</DialogContent> |
||||||
|
</Dialog> |
||||||
|
) |
||||||
|
} |
||||||
@ -0,0 +1,23 @@ |
|||||||
|
import * as React from 'react' |
||||||
|
|
||||||
|
import { cn } from '@renderer/lib/utils' |
||||||
|
|
||||||
|
export interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {} |
||||||
|
|
||||||
|
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>( |
||||||
|
({ className, ...props }, ref) => { |
||||||
|
return ( |
||||||
|
<textarea |
||||||
|
className={cn( |
||||||
|
'flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm', |
||||||
|
className |
||||||
|
)} |
||||||
|
ref={ref} |
||||||
|
{...props} |
||||||
|
/> |
||||||
|
) |
||||||
|
} |
||||||
|
) |
||||||
|
Textarea.displayName = 'Textarea' |
||||||
|
|
||||||
|
export { Textarea } |
||||||
@ -0,0 +1,17 @@ |
|||||||
|
import PostDialog from '@renderer/components/PostDialog' |
||||||
|
import { Button } from '@renderer/components/ui/button' |
||||||
|
import { useNostr } from '@renderer/providers/NostrProvider' |
||||||
|
import { PencilLine } from 'lucide-react' |
||||||
|
|
||||||
|
export default function PostButton() { |
||||||
|
const { pubkey } = useNostr() |
||||||
|
if (!pubkey) return null |
||||||
|
|
||||||
|
return ( |
||||||
|
<PostDialog> |
||||||
|
<Button variant="titlebar" size="titlebar"> |
||||||
|
<PencilLine /> |
||||||
|
</Button> |
||||||
|
</PostDialog> |
||||||
|
) |
||||||
|
} |
||||||
Loading…
Reference in new issue