diff --git a/package-lock.json b/package-lock.json
index 6338058..4097658 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -22,6 +22,7 @@
"@radix-ui/react-separator": "^1.1.1",
"@radix-ui/react-slot": "^1.1.1",
"@radix-ui/react-switch": "^1.1.2",
+ "@radix-ui/react-tabs": "^1.1.2",
"@radix-ui/react-toast": "^1.2.4",
"blurhash": "^2.0.5",
"class-variance-authority": "^0.7.1",
@@ -3088,6 +3089,35 @@
}
}
},
+ "node_modules/@radix-ui/react-tabs": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.2.tgz",
+ "integrity": "sha512-9u/tQJMcC2aGq7KXpGivMm1mgq7oRJKXphDwdypPd/j21j/2znamPU8WkXgnhUaTrSFNIt8XhOyCAupg8/GbwQ==",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.1",
+ "@radix-ui/react-context": "1.1.1",
+ "@radix-ui/react-direction": "1.1.0",
+ "@radix-ui/react-id": "1.1.0",
+ "@radix-ui/react-presence": "1.1.2",
+ "@radix-ui/react-primitive": "2.0.1",
+ "@radix-ui/react-roving-focus": "1.1.1",
+ "@radix-ui/react-use-controllable-state": "1.1.0"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@radix-ui/react-toast": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@radix-ui/react-toast/-/react-toast-1.2.4.tgz",
diff --git a/package.json b/package.json
index 3a37ad5..995919d 100644
--- a/package.json
+++ b/package.json
@@ -32,6 +32,7 @@
"@radix-ui/react-separator": "^1.1.1",
"@radix-ui/react-slot": "^1.1.1",
"@radix-ui/react-switch": "^1.1.2",
+ "@radix-ui/react-tabs": "^1.1.2",
"@radix-ui/react-toast": "^1.2.4",
"blurhash": "^2.0.5",
"class-variance-authority": "^0.7.1",
diff --git a/src/components/ImageGallery/index.tsx b/src/components/ImageGallery/index.tsx
index 1ed375d..d84b4ba 100644
--- a/src/components/ImageGallery/index.tsx
+++ b/src/components/ImageGallery/index.tsx
@@ -2,6 +2,7 @@ import { cn } from '@/lib/utils'
import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { TImageInfo } from '@/types'
import { ReactNode, useState } from 'react'
+import { createPortal } from 'react-dom'
import Lightbox from 'yet-another-react-lightbox'
import Zoom from 'yet-another-react-lightbox/plugins/zoom'
import Image from '../Image'
@@ -81,21 +82,27 @@ export default function ImageGallery({
return (
{imageContent}
-
e.stopPropagation()}>
- ({ src: url }))}
- plugins={[Zoom]}
- open={index >= 0}
- close={() => setIndex(-1)}
- controller={{
- closeOnBackdropClick: true,
- closeOnPullUp: true,
- closeOnPullDown: true
- }}
- styles={{ toolbar: { paddingTop: '2.25rem' } }}
- />
-
+ {index >= 0 &&
+ createPortal(
+
e.stopPropagation()}>
+ ({ src: url }))}
+ plugins={[Zoom]}
+ open={index >= 0}
+ close={() => setIndex(-1)}
+ controller={{
+ closeOnBackdropClick: true,
+ closeOnPullUp: true,
+ closeOnPullDown: true
+ }}
+ styles={{
+ toolbar: { paddingTop: '2.25rem' }
+ }}
+ />
+
,
+ document.body
+ )}
{isNsfw &&
}
)
diff --git a/src/components/PictureNoteCard/index.tsx b/src/components/PictureNoteCard/index.tsx
index b810d75..5987a08 100644
--- a/src/components/PictureNoteCard/index.tsx
+++ b/src/components/PictureNoteCard/index.tsx
@@ -1,11 +1,9 @@
import { extractFirstPictureFromPictureEvent } from '@/lib/event'
import { toNote } from '@/lib/link'
+import { tagNameEquals } from '@/lib/tag'
import { cn } from '@/lib/utils'
import { useSecondaryPage } from '@/PageManager'
import { Event } from 'nostr-tools'
-import Image from '../Image'
-import UserAvatar from '../UserAvatar'
-import Username from '../Username'
import { useMemo } from 'react'
import {
embedded,
@@ -13,6 +11,9 @@ import {
embeddedNostrNpubRenderer,
embeddedNostrProfileRenderer
} from '../Embedded'
+import Image from '../Image'
+import UserAvatar from '../UserAvatar'
+import Username from '../Username'
export default function PictureNoteCard({
event,
@@ -23,21 +24,20 @@ export default function PictureNoteCard({
}) {
const { push } = useSecondaryPage()
const firstImage = extractFirstPictureFromPictureEvent(event)
- const content = useMemo(
- () =>
- embedded(event.content, [
- embeddedNostrNpubRenderer,
- embeddedNostrProfileRenderer,
- embeddedHashtagRenderer
- ]),
- [event]
- )
+ const title = useMemo(() => {
+ const title = event.tags.find(tagNameEquals('title'))?.[1] ?? event.content
+ return embedded(title, [
+ embeddedNostrNpubRenderer,
+ embeddedNostrProfileRenderer,
+ embeddedHashtagRenderer
+ ])
+ }, [event])
if (!firstImage) return null
return (
push(toNote(event))}>
-
{content}
+
{title}
diff --git a/src/components/PostEditor/PostContent.tsx b/src/components/PostEditor/NormalPostContent.tsx
similarity index 77%
rename from src/components/PostEditor/PostContent.tsx
rename to src/components/PostEditor/NormalPostContent.tsx
index 0dcc1ba..001b3fd 100644
--- a/src/components/PostEditor/PostContent.tsx
+++ b/src/components/PostEditor/NormalPostContent.tsx
@@ -4,12 +4,7 @@ import { Switch } from '@/components/ui/switch'
import { Textarea } from '@/components/ui/textarea'
import { StorageKey } from '@/constants'
import { useToast } from '@/hooks/use-toast'
-import {
- createCommentDraftEvent,
- createPictureNoteDraftEvent,
- createShortTextNoteDraftEvent
-} from '@/lib/draft-event'
-import { extractImagesFromContent } from '@/lib/event'
+import { createCommentDraftEvent, createShortTextNoteDraftEvent } from '@/lib/draft-event'
import { useNostr } from '@/providers/NostrProvider'
import client from '@/services/client.service'
import { ChevronDown, LoaderCircle } from 'lucide-react'
@@ -20,7 +15,7 @@ import Mentions from './Mentions'
import Preview from './Preview'
import Uploader from './Uploader'
-export default function PostContent({
+export default function NormalPostContent({
defaultContent = '',
parentEvent,
close
@@ -37,19 +32,12 @@ export default function PostContent({
const [posting, setPosting] = useState(false)
const [showMoreOptions, setShowMoreOptions] = useState(false)
const [addClientTag, setAddClientTag] = useState(false)
- const [isPictureNote, setIsPictureNote] = useState(false)
- const [hasImages, setHasImages] = useState(false)
const canPost = !!content && !posting
useEffect(() => {
setAddClientTag(window.localStorage.getItem(StorageKey.ADD_CLIENT_TAG) === 'true')
}, [])
- useEffect(() => {
- const { images } = extractImagesFromContent(content)
- setHasImages(!!images && images.length > 0)
- }, [content])
-
const handleTextareaChange = (e: React.ChangeEvent
) => {
setContent(e.target.value)
}
@@ -69,18 +57,13 @@ export default function PostContent({
const relayList = await client.fetchRelayList(parentEvent.pubkey)
additionalRelayUrls.push(...relayList.read.slice(0, 5))
}
- if (isPictureNote && !hasImages) {
- throw new Error(t('Picture note requires images'))
- }
const draftEvent =
- isPictureNote && !parentEvent && hasImages
- ? await createPictureNoteDraftEvent(content, pictureInfos, { addClientTag })
- : parentEvent && parentEvent.kind !== kinds.ShortTextNote
- ? await createCommentDraftEvent(content, parentEvent, pictureInfos, { addClientTag })
- : await createShortTextNoteDraftEvent(content, pictureInfos, {
- parentEvent,
- addClientTag
- })
+ parentEvent && parentEvent.kind !== kinds.ShortTextNote
+ ? await createCommentDraftEvent(content, parentEvent, pictureInfos, { addClientTag })
+ : await createShortTextNoteDraftEvent(content, pictureInfos, {
+ parentEvent,
+ addClientTag
+ })
await publish(draftEvent, additionalRelayUrls)
setContent('')
close()
@@ -177,21 +160,6 @@ export default function PostContent({
{t('Show others this was sent via Jumble')}
- {!parentEvent && (
- <>
-
-
-
-
-
- {t('A special note for picture-first clients like Olas')}
-
- >
- )}
)}
diff --git a/src/components/PostEditor/PicturePostContent.tsx b/src/components/PostEditor/PicturePostContent.tsx
new file mode 100644
index 0000000..b382c3c
--- /dev/null
+++ b/src/components/PostEditor/PicturePostContent.tsx
@@ -0,0 +1,233 @@
+import { Button } from '@/components/ui/button'
+import { Label } from '@/components/ui/label'
+import { Switch } from '@/components/ui/switch'
+import { Textarea } from '@/components/ui/textarea'
+import { StorageKey } from '@/constants'
+import { useToast } from '@/hooks/use-toast'
+import { createPictureNoteDraftEvent } from '@/lib/draft-event'
+import { useNostr } from '@/providers/NostrProvider'
+import { ChevronDown, LoaderCircle, X } from 'lucide-react'
+import { Dispatch, SetStateAction, useEffect, useState } from 'react'
+import { createPortal } from 'react-dom'
+import { useTranslation } from 'react-i18next'
+import Lightbox from 'yet-another-react-lightbox'
+import Zoom from 'yet-another-react-lightbox/plugins/zoom'
+import Image from '../Image'
+import Mentions from './Mentions'
+import Uploader from './Uploader'
+
+export default function PicturePostContent({ close }: { close: () => void }) {
+ const { t } = useTranslation()
+ const { toast } = useToast()
+ const { publish, checkLogin } = useNostr()
+ const [content, setContent] = useState('')
+ const [pictureInfos, setPictureInfos] = useState<{ url: string; tags: string[][] }[]>([])
+ const [posting, setPosting] = useState(false)
+ const [showMoreOptions, setShowMoreOptions] = useState(false)
+ const [addClientTag, setAddClientTag] = useState(false)
+ const canPost = !!content && !posting && pictureInfos.length > 0
+
+ useEffect(() => {
+ setAddClientTag(window.localStorage.getItem(StorageKey.ADD_CLIENT_TAG) === 'true')
+ }, [])
+
+ const handleTextareaChange = (e: React.ChangeEvent
) => {
+ setContent(e.target.value)
+ }
+
+ const post = async (e: React.MouseEvent) => {
+ e.stopPropagation()
+ checkLogin(async () => {
+ if (!canPost) {
+ close()
+ return
+ }
+
+ setPosting(true)
+ try {
+ if (!pictureInfos.length) {
+ throw new Error(t('Picture note requires images'))
+ }
+ const draftEvent = await createPictureNoteDraftEvent(content, pictureInfos, {
+ addClientTag
+ })
+ await publish(draftEvent)
+ setContent('')
+ close()
+ } catch (error) {
+ if (error instanceof AggregateError) {
+ error.errors.forEach((e) =>
+ toast({
+ variant: 'destructive',
+ title: t('Failed to post'),
+ description: e.message
+ })
+ )
+ } else if (error instanceof Error) {
+ toast({
+ variant: 'destructive',
+ title: t('Failed to post'),
+ description: error.message
+ })
+ }
+ console.error(error)
+ return
+ } finally {
+ setPosting(false)
+ }
+ toast({
+ title: t('Post successful'),
+ description: t('Your post has been published')
+ })
+ })
+ }
+
+ const onAddClientTagChange = (checked: boolean) => {
+ setAddClientTag(checked)
+ window.localStorage.setItem(StorageKey.ADD_CLIENT_TAG, checked.toString())
+ }
+
+ return (
+
+
+ {t('A special note for picture-first clients like Olas')}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {showMoreOptions && (
+
+
+
+
+
+
+ {t('Show others this was sent via Jumble')}
+
+
+ )}
+
+
+
+
+
+ )
+}
+
+function PictureUploader({
+ pictureInfos,
+ setPictureInfos
+}: {
+ pictureInfos: { url: string; tags: string[][] }[]
+ setPictureInfos: Dispatch<
+ SetStateAction<
+ {
+ url: string
+ tags: string[][]
+ }[]
+ >
+ >
+}) {
+ const [index, setIndex] = useState(-1)
+
+ return (
+ <>
+
+ {pictureInfos.map(({ url }, index) => (
+
+ {
+ e.stopPropagation()
+ setIndex(index)
+ }}
+ />
+
+
+ ))}
+
{
+ setPictureInfos((prev) => [...prev, { url, tags }])
+ }}
+ />
+
+ {index >= 0 &&
+ createPortal(
+ e.stopPropagation()}>
+ ({ src: url }))}
+ plugins={[Zoom]}
+ open={index >= 0}
+ close={() => setIndex(-1)}
+ controller={{
+ closeOnBackdropClick: true,
+ closeOnPullUp: true,
+ closeOnPullDown: true
+ }}
+ styles={{ toolbar: { paddingTop: '2.25rem' } }}
+ />
+
,
+ document.body
+ )}
+ >
+ )
+}
diff --git a/src/components/PostEditor/Uploader.tsx b/src/components/PostEditor/Uploader.tsx
index 66e2a6f..28580c3 100644
--- a/src/components/PostEditor/Uploader.tsx
+++ b/src/components/PostEditor/Uploader.tsx
@@ -1,14 +1,17 @@
import { Button } from '@/components/ui/button'
import { useToast } from '@/hooks/use-toast'
+import { cn } from '@/lib/utils'
import { useNostr } from '@/providers/NostrProvider'
-import { ImageUp, LoaderCircle } from 'lucide-react'
+import { ImageUp, Loader, LoaderCircle, Plus } from 'lucide-react'
import { useRef, useState } from 'react'
import { z } from 'zod'
export default function Uploader({
- onUploadSuccess
+ onUploadSuccess,
+ variant = 'button'
}: {
onUploadSuccess: ({ url, tags }: { url: string; tags: string[][] }) => void
+ variant?: 'button' | 'big'
}) {
const [uploading, setUploading] = useState(false)
const { signHttpAuth } = useNostr()
@@ -62,20 +65,46 @@ export default function Uploader({
}
const handleUploadClick = () => {
- fileInputRef.current?.click()
+ if (fileInputRef.current) {
+ fileInputRef.current.value = '' // clear the value so that the same file can be uploaded again
+ fileInputRef.current.click()
+ }
+ }
+
+ if (variant === 'button') {
+ return (
+ <>
+
+
+ >
+ )
}
return (
<>
-
+
>
)
diff --git a/src/components/PostEditor/index.tsx b/src/components/PostEditor/index.tsx
index 67b5fa4..3558333 100644
--- a/src/components/PostEditor/index.tsx
+++ b/src/components/PostEditor/index.tsx
@@ -5,19 +5,16 @@ import {
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
-import {
- Drawer,
- DrawerContent,
- DrawerDescription,
- DrawerHeader,
- DrawerTitle
-} from '@/components/ui/drawer'
import { ScrollArea } from '@/components/ui/scroll-area'
+import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { Event } from 'nostr-tools'
-import { Dispatch } from 'react'
-import PostContent from './PostContent'
+import { Dispatch, useMemo } from 'react'
+import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from '../ui/sheet'
+import NormalPostContent from './NormalPostContent'
+import PicturePostContent from './PicturePostContent'
import Title from './Title'
+import { useTranslation } from 'react-i18next'
export default function PostEditor({
defaultContent = '',
@@ -30,32 +27,58 @@ export default function PostEditor({
open: boolean
setOpen: Dispatch
}) {
+ const { t } = useTranslation()
const { isSmallScreen } = useScreenSize()
+ const content = useMemo(() => {
+ return parentEvent ? (
+ setOpen(false)}
+ />
+ ) : (
+
+
+ {t('Normal Post')}
+ {t('Picture Post')}
+
+
+ setOpen(false)}
+ />
+
+
+ setOpen(false)} />
+
+
+ )
+ }, [parentEvent])
+
if (isSmallScreen) {
return (
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+ {content}
+
+
+
+
)
}
return (
-