25 changed files with 483 additions and 98 deletions
@ -1,17 +1,26 @@
@@ -1,17 +1,26 @@
|
||||
import { useNostr } from '@renderer/providers/NostrProvider' |
||||
import { useNoteStats } from '@renderer/providers/NoteStatsProvider' |
||||
import { MessageCircle } from 'lucide-react' |
||||
import { Event } from 'nostr-tools' |
||||
import { useMemo } from 'react' |
||||
import PostDialog from '../PostDialog' |
||||
import { formatCount } from './utils' |
||||
|
||||
export default function ReplyButton({ event }: { event: Event }) { |
||||
const { noteStatsMap } = useNoteStats() |
||||
const { pubkey } = useNostr() |
||||
const { replyCount } = useMemo(() => noteStatsMap.get(event.id) ?? {}, [noteStatsMap, event.id]) |
||||
|
||||
return ( |
||||
<div className="flex gap-1 items-center text-muted-foreground"> |
||||
<MessageCircle size={16} /> |
||||
<div className="text-xs">{formatCount(replyCount)}</div> |
||||
</div> |
||||
<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} /> |
||||
<div className="text-xs">{formatCount(replyCount)}</div> |
||||
</button> |
||||
</PostDialog> |
||||
) |
||||
} |
||||
|
||||
@ -0,0 +1,47 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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