diff --git a/package.json b/package.json index 9288c2176..48eb1be24 100644 --- a/package.json +++ b/package.json @@ -53,8 +53,7 @@ "applesauce-react": "next", "applesauce-signer": "next", "bech32": "^2.0.0", - "blossom-client-sdk": "^2.1.1", - "blossom-drive-sdk": "^0.4.1", + "blossom-client-sdk": "next", "blurhash": "^2.0.5", "canvas-confetti": "^1.9.3", "chart.js": "^4.4.7", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4392dca5a..89e97e7de 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -124,11 +124,8 @@ importers: specifier: ^2.0.0 version: 2.0.0 blossom-client-sdk: - specifier: ^2.1.1 - version: 2.1.1 - blossom-drive-sdk: - specifier: ^0.4.1 - version: 0.4.1(typescript@5.7.2) + specifier: next + version: 0.0.0-next-20250104165538 blurhash: specifier: ^2.0.5 version: 2.0.5 @@ -2021,16 +2018,10 @@ packages: bezier-js@6.1.4: resolution: {integrity: sha512-PA0FW9ZpcHbojUCMu28z9Vg/fNkwTj5YhusSAjHHDfHDGLxJ6YUKrAN2vk1fP2MMOxVw4Oko16FMlRGVBGqLKg==} - blossom-client-sdk@0.7.0: - resolution: {integrity: sha512-xG0HiuhFcK6UpmYjJ4vRPm3APMrRf+MQDfZWlNRTxs2gEETfqbhYm5pCl2hPfLjpEcFSDXgr3sLCh6C77ABKgg==} - - blossom-client-sdk@2.1.1: - resolution: {integrity: sha512-a95eZV7W5/QPN30p0s2K8ZxX0vwMXsnl2JIJDXaOu0nDVxikfKINa/7mhTRtA2i3dzjQ378FPET8vwn0GjtFzg==} + blossom-client-sdk@0.0.0-next-20250104165538: + resolution: {integrity: sha512-JOVhS5lIr/2+JB1XlTywrYmuhhdYvehn4brVmoraHdAq1SGx1z0TXW9QgybNhDU2suf56bTYS0pE3D/XvAmlVA==} engines: {node: '>=18'} - blossom-drive-sdk@0.4.1: - resolution: {integrity: sha512-X4OIA4+X4zpZy11zP6ekE61LHFIOCvS+H+McqsyDVtGrwFeFSuqfZVyKpg1QuQTlxSVIlJVX0jt9+gHEfdGqWA==} - blurhash@2.0.5: resolution: {integrity: sha512-cRygWd7kGBQO3VEhPiTgq4Wc43ctsM+o46urrmPOiuAe+07fzlSB9OJVdpgDL0jPqXUVQ9ht7aq7kxOeJHRK+w==} @@ -2203,9 +2194,6 @@ packages: crelt@1.0.6: resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==} - cross-fetch@4.1.0: - resolution: {integrity: sha512-uKm5PU+MHTootlWEY+mZ4vvXoCn4fLQxT9dSc1sXVMSFkINTJVN8cAQROpwcKm8bJ/c7rgZVIBWzH5T78sNZZw==} - cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -3292,11 +3280,6 @@ packages: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} - mime@4.0.6: - resolution: {integrity: sha512-4rGt7rvQHBbaSOF9POGkk1ocRP16Md1x36Xma8sz8h8/vfCUI2OtEIeCqe4Ofes853x4xDoPiFLIT47J5fI/7A==} - engines: {node: '>=16'} - hasBin: true - minimatch@3.1.2: resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} @@ -3359,15 +3342,6 @@ packages: ngraph.random@1.1.0: resolution: {integrity: sha512-h25UdUN/g8U7y29TzQtRm/GvGr70lK37yQPvPKXXuVfs7gCm82WipYFZcksQfeKumtOemAzBIcT7lzzyK/edLw==} - node-fetch@2.7.0: - resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} - engines: {node: 4.x || >=6.0.0} - peerDependencies: - encoding: ^0.1.0 - peerDependenciesMeta: - encoding: - optional: true - node-releases@2.0.19: resolution: {integrity: sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==} @@ -4206,9 +4180,6 @@ packages: toggle-selection@1.0.6: resolution: {integrity: sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ==} - tr46@0.0.3: - resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} - tr46@1.0.1: resolution: {integrity: sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA==} @@ -4418,9 +4389,6 @@ packages: w3c-keyname@2.2.8: resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==} - webidl-conversions@3.0.1: - resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} - webidl-conversions@4.0.2: resolution: {integrity: sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==} @@ -4435,9 +4403,6 @@ packages: resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} engines: {node: '>=18'} - whatwg-url@5.0.0: - resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} - whatwg-url@7.1.0: resolution: {integrity: sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg==} @@ -6616,31 +6581,11 @@ snapshots: bezier-js@6.1.4: {} - blossom-client-sdk@0.7.0: - dependencies: - '@noble/hashes': 1.7.0 - cross-fetch: 4.1.0 - transitivePeerDependencies: - - encoding - - blossom-client-sdk@2.1.1: + blossom-client-sdk@0.0.0-next-20250104165538: dependencies: '@cashu/cashu-ts': 2.1.0 '@noble/hashes': 1.7.0 - blossom-drive-sdk@0.4.1(typescript@5.7.2): - dependencies: - '@noble/hashes': 1.7.0 - '@scure/base': 1.2.1 - blossom-client-sdk: 0.7.0 - eventemitter3: 5.0.1 - mime: 4.0.6 - nanoid: 5.0.9 - nostr-tools: 2.10.4(typescript@5.7.2) - transitivePeerDependencies: - - encoding - - typescript - blurhash@2.0.5: {} boolbase@1.0.0: {} @@ -6845,12 +6790,6 @@ snapshots: crelt@1.0.6: {} - cross-fetch@4.1.0: - dependencies: - node-fetch: 2.7.0 - transitivePeerDependencies: - - encoding - cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -8237,8 +8176,6 @@ snapshots: braces: 3.0.3 picomatch: 2.3.1 - mime@4.0.6: {} - minimatch@3.1.2: dependencies: brace-expansion: 1.1.11 @@ -8299,10 +8236,6 @@ snapshots: ngraph.random@1.1.0: {} - node-fetch@2.7.0: - dependencies: - whatwg-url: 5.0.0 - node-releases@2.0.19: {} nostr-idb@2.2.0(typescript@5.7.2): @@ -9278,8 +9211,6 @@ snapshots: toggle-selection@1.0.6: {} - tr46@0.0.3: {} - tr46@1.0.1: dependencies: punycode: 2.3.1 @@ -9466,8 +9397,6 @@ snapshots: w3c-keyname@2.2.8: {} - webidl-conversions@3.0.1: {} - webidl-conversions@4.0.2: {} webln@0.3.2: @@ -9480,11 +9409,6 @@ snapshots: whatwg-mimetype@4.0.0: {} - whatwg-url@5.0.0: - dependencies: - tr46: 0.0.3 - webidl-conversions: 3.0.1 - whatwg-url@7.1.0: dependencies: lodash.sortby: 4.7.0 diff --git a/src/app.tsx b/src/app.tsx index 5272a0d5f..bca1bad99 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -109,6 +109,9 @@ import ArticleView from "./views/articles/article"; import WalletView from "./views/wallet"; import SupportView from "./views/support"; import UserMediaPostsTab from "./views/user/media-posts"; +import NewView from "./views/new"; +import NewNoteView from "./views/new/note"; +import NewMediaPostView from "./views/new/media"; const TracksView = lazy(() => import("./views/tracks")); const UserTracksTab = lazy(() => import("./views/user/tracks")); const UserVideosTab = lazy(() => import("./views/user/videos")); @@ -253,6 +256,14 @@ const router = createHashRouter([ path: "/", element: , children: [ + { + path: "new", + children: [ + { path: "", element: }, + { path: "note", element: }, + { path: "media", element: }, + ], + }, { path: "/u/:pubkey", element: , diff --git a/src/components/icons.tsx b/src/components/icons.tsx index cb5e852b2..762565cb8 100644 --- a/src/components/icons.tsx +++ b/src/components/icons.tsx @@ -70,6 +70,7 @@ import Modem02 from "./icons/modem-02"; import BookOpen01 from "./icons/book-open-01"; import Edit04 from "./icons/edit-04"; import Film02 from "./icons/film-02"; +import Camera01 from "./icons/camera-01"; const defaultProps: IconProps = { boxSize: 4 }; @@ -253,3 +254,5 @@ export const WikiIcon = BookOpen01; export const ArticleIcon = Edit04; export const VideoIcon = Film02; + +export const MediaIcon = Camera01 diff --git a/src/components/layout/account-switcher.tsx b/src/components/layout/account-switcher.tsx index 6138dd3e0..c285f5e1e 100644 --- a/src/components/layout/account-switcher.tsx +++ b/src/components/layout/account-switcher.tsx @@ -61,7 +61,6 @@ export default function AccountSwitcher() { borderWidth={1} display="flex" gap="2" - mb="2" alignItems="center" flexGrow={1} onClick={onToggle} diff --git a/src/components/layout/desktop-side-nav.tsx b/src/components/layout/desktop-side-nav.tsx index 2f481b713..c6e4719bc 100644 --- a/src/components/layout/desktop-side-nav.tsx +++ b/src/components/layout/desktop-side-nav.tsx @@ -4,11 +4,11 @@ import { Link as RouterLink } from "react-router-dom"; import { css } from "@emotion/react"; import { useObservable } from "applesauce-react/hooks"; +import Plus from "../icons/plus"; import useCurrentAccount from "../../hooks/use-current-account"; import AccountSwitcher from "./account-switcher"; import NavItems from "./nav-items"; import { PostModalContext } from "../../providers/route/post-modal-provider"; -import { WritingIcon } from "../icons"; import { offlineMode } from "../../services/offline-mode"; import WifiOff from "../icons/wifi-off"; import TaskManagerButtons from "./task-manager-buttons"; @@ -65,15 +65,15 @@ export default function DesktopSideNav(props: Omit) { <> )} diff --git a/src/components/layout/mobile-bottom-nav.tsx b/src/components/layout/mobile-bottom-nav.tsx index 3f3814e20..bcfbe16e8 100644 --- a/src/components/layout/mobile-bottom-nav.tsx +++ b/src/components/layout/mobile-bottom-nav.tsx @@ -1,9 +1,8 @@ import { Avatar, Flex, FlexProps, IconButton, useDisclosure } from "@chakra-ui/react"; -import { useContext, useEffect } from "react"; -import { useLocation, useNavigate } from "react-router-dom"; +import { useEffect } from "react"; +import { useLocation, Link as RouterLink } from "react-router-dom"; import useCurrentAccount from "../../hooks/use-current-account"; -import { PostModalContext } from "../../providers/route/post-modal-provider"; import { DirectMessagesIcon, NotesIcon, NotificationsIcon, PlusCircleIcon, SearchIcon } from "../icons"; import UserAvatar from "../user/user-avatar"; import MobileSideDrawer from "./mobile-side-drawer"; @@ -11,8 +10,6 @@ import Rocket02 from "../icons/rocket-02"; export default function MobileBottomNav(props: Omit) { const { isOpen, onOpen, onClose } = useDisclosure(); - const { openModal } = useContext(PostModalContext); - const navigate = useNavigate(); const account = useCurrentAccount(); const location = useLocation(); @@ -26,50 +23,41 @@ export default function MobileBottomNav(props: Omit) { ) : ( )} + } aria-label="Home" flexGrow="1" size="md" to="/" /> } - aria-label="Home" - onClick={() => navigate("/")} - flexGrow="1" - size="md" - /> - } aria-label="Search" - onClick={() => navigate(`/search`)} flexGrow="1" size="md" + to="/search" /> } - aria-label="New Note" - onClick={() => { - openModal(); - }} + aria-label="Create new" + title="Create new" variant="solid" colorScheme="primary" - isDisabled={account?.readonly ?? true} + to="/new" /> } aria-label="Messages" - onClick={() => navigate(`/dm`)} flexGrow="1" size="md" + to="/dm" /> } aria-label="Notifications" - onClick={() => navigate("/notifications")} flexGrow="1" size="md" + to="/notifications" /> - } - aria-label="Launchpad" - onClick={() => navigate("/launchpad")} - isDisabled={account?.readonly ?? true} - /> + } aria-label="Launchpad" to="/launchpad" /> diff --git a/src/components/post-modal/index.tsx b/src/components/post-modal/index.tsx index da72ee9d3..6e1b2f71f 100644 --- a/src/components/post-modal/index.tsx +++ b/src/components/post-modal/index.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef, useState } from "react"; +import { useRef, useState } from "react"; import { Modal, ModalOverlay, @@ -27,7 +27,7 @@ import { Text, } from "@chakra-ui/react"; import { useForm } from "react-hook-form"; -import { EventTemplate, UnsignedEvent } from "nostr-tools"; +import { UnsignedEvent } from "nostr-tools"; import { useAsync, useThrottle } from "react-use"; import { useEventFactory, useObservable } from "applesauce-react/hooks"; import { Emoji, ZapSplit } from "applesauce-core/helpers"; @@ -39,7 +39,7 @@ import { TrustProvider } from "../../providers/local/trust-provider"; import MagicTextArea, { RefType } from "../magic-textarea"; import { useContextEmojis } from "../../providers/global/emoji-provider"; import CommunitySelect from "./community-select"; -import ZapSplitCreator from "./zap-split-creator"; +import ZapSplitCreator from "../../views/new/note/zap-split-creator"; import useCurrentAccount from "../../hooks/use-current-account"; import useCacheForm from "../../hooks/use-cache-form"; import useTextAreaUploadFile, { useTextAreaInsertTextWithForm } from "../../hooks/use-textarea-upload-file"; @@ -51,7 +51,7 @@ import { TextNoteContents } from "../note/timeline-note/text-note-contents"; import localSettings from "../../services/local-settings"; import useLocalStorageDisclosure from "../../hooks/use-localstorage-disclosure"; import InsertGifButton from "../gif/insert-gif-button"; -import InsertImageButton from "./insert-image-button"; +import InsertImageButton from "../../views/new/note/insert-image-button"; type FormValues = { subject: string; diff --git a/src/helpers/media-upload/blossom.ts b/src/helpers/media-upload/blossom.ts index c51a65bae..37c07d166 100644 --- a/src/helpers/media-upload/blossom.ts +++ b/src/helpers/media-upload/blossom.ts @@ -1,5 +1,5 @@ import { BlobDescriptor, createUploadAuth, ServerType, Signer } from "blossom-client-sdk"; -import { multiServerUpload, MultiServerUploadOptions } from "blossom-client-sdk/actions/upload"; +import { multiServerUpload, MultiServerUploadOptions } from "blossom-client-sdk/actions/multi-server"; export async function simpleMultiServerUpload( servers: T[], @@ -8,8 +8,11 @@ export async function simpleMultiServerUpload opts?: MultiServerUploadOptions, ): Promise { const results = await multiServerUpload(servers, file, { + isMedia: file.type.startsWith("image/") || file.type.startsWith("video/"), + mediaUploadBehavior: "any", + mediaUploadFallback: true, ...opts, - onAuth: (_server, blob) => createUploadAuth(signer, blob), + onAuth: (_server, blob, type) => createUploadAuth(signer, blob, { type }), }); let blob: BlobDescriptor | null = null; diff --git a/src/views/channels/components/send-message-form.tsx b/src/views/channels/components/send-message-form.tsx index 6e41db52b..7f02e3a7b 100644 --- a/src/views/channels/components/send-message-form.tsx +++ b/src/views/channels/components/send-message-form.tsx @@ -12,7 +12,7 @@ import { NostrEvent } from "../../../types/nostr-event"; import { useContextEmojis } from "../../../providers/global/emoji-provider"; import { usePublishEvent } from "../../../providers/global/publish-provider"; import InsertGifButton from "../../../components/gif/insert-gif-button"; -import InsertImageButton from "../../../components/post-modal/insert-image-button"; +import InsertImageButton from "../../new/note/insert-image-button"; export default function ChannelMessageForm({ channel, diff --git a/src/views/launchpad/index.tsx b/src/views/launchpad/index.tsx index 44d014362..ed1c8f797 100644 --- a/src/views/launchpad/index.tsx +++ b/src/views/launchpad/index.tsx @@ -1,10 +1,8 @@ -import { useContext } from "react"; import { Button, Container, Flex, IconButton } from "@chakra-ui/react"; import { Link as RouterLink } from "react-router-dom"; import VerticalPageLayout from "../../components/vertical-page-layout"; import RequireCurrentAccount from "../../providers/route/require-current-account"; -import { PostModalContext } from "../../providers/route/post-modal-provider"; import AccountSwitcher from "../../components/layout/account-switcher"; import { SettingsIcon } from "../../components/icons"; import { ErrorBoundary } from "../../components/error-boundary"; @@ -15,31 +13,36 @@ import DMsCard from "./components/dms-card"; import NotificationsCard from "./components/notifications-card"; import ToolsCard from "./components/tools-card"; import StreamsCard from "./components/streams-card"; +import Plus from "../../components/icons/plus"; function LaunchpadPage() { - const { openModal } = useContext(PostModalContext); - return ( - + + + + } aria-label="Settings" title="Settings" size="lg" - borderRadius="50%" to="/settings" /> - - - - + diff --git a/src/views/new/index.tsx b/src/views/new/index.tsx new file mode 100644 index 000000000..ca9d15764 --- /dev/null +++ b/src/views/new/index.tsx @@ -0,0 +1,37 @@ +import { Card, CardBody, ComponentWithAs, Flex, Heading, IconProps, LinkBox, SimpleGrid, Text } from "@chakra-ui/react"; +import { Link as RouterLink } from "react-router-dom"; + +import VerticalPageLayout from "../../components/vertical-page-layout"; +import { MediaIcon, NotesIcon } from "../../components/icons"; +import HoverLinkOverlay from "../../components/hover-link-overlay"; + +const NEW_TYPES: { title: string; path: string; summary?: string; icon: ComponentWithAs<"svg", IconProps> }[] = [ + { title: "Text Note", path: "/new/note", summary: "A short text post with media", icon: NotesIcon }, + { title: "Media Post", path: "/new/media", summary: "Image and video posts", icon: MediaIcon }, +]; + +export default function NewView() { + return ( + + Create new... + + {NEW_TYPES.map(({ title, path, icon: Icon, summary }) => ( + + + + + + + {title} + + + + {summary && {summary}} + + + + ))} + + + ); +} diff --git a/src/views/new/media/index.tsx b/src/views/new/media/index.tsx new file mode 100644 index 000000000..e4e8ab106 --- /dev/null +++ b/src/views/new/media/index.tsx @@ -0,0 +1,169 @@ +import { + Alert, + AlertIcon, + Button, + Divider, + Flex, + Heading, + Input, + Spacer, + Switch, + useDisclosure, + useToast, +} from "@chakra-ui/react"; +import { nanoid } from "nanoid"; +import { useForm } from "react-hook-form"; + +import VerticalPageLayout from "../../../components/vertical-page-layout"; +import BackButton from "../../../components/router/back-button"; +import useCacheForm from "../../../hooks/use-cache-form"; +import useCurrentAccount from "../../../hooks/use-current-account"; +import { useEventFactory } from "applesauce-react/hooks"; +import { usePublishEvent } from "../../../providers/global/publish-provider"; +import MagicTextArea from "../../../components/magic-textarea"; +import NewMediaSlide from "./new-media-slide"; +import MediaSlide from "./media-slide"; +import { ErrorBoundary } from "../../../components/error-boundary"; +import ZapSplitCreator, { Split } from "../note/zap-split-creator"; + +type FormValues = { + content: string; + nsfw: boolean; + nsfwReason: string; + media: { id: string; alt?: string; file: File }[]; + split: Split[]; +}; + +const setOptions = { shouldDirty: true, shouldTouch: true }; + +export default function NewMediaPostView() { + const toast = useToast(); + const account = useCurrentAccount()!; + const factory = useEventFactory(); + const publish = usePublishEvent(); + + const advanced = useDisclosure(); + + const { getValues, reset, formState, handleSubmit, setValue, watch, register } = useForm({ + mode: "all", + defaultValues: { media: [], content: "", nsfw: false, nsfwReason: "", split: [] }, + }); + + watch("content"); + watch("media"); + watch("nsfw"); + watch("split"); + + // TODO: cache for needs to save File and Blobs + const clearFormCache = useCacheForm( + "new-media-post", + // @ts-expect-error + getValues, + reset, + formState, + ); + + const submit = handleSubmit(async (values) => { + try { + // do something + + clearFormCache(); + } catch (error) { + if (error instanceof Error) toast({ status: "error", description: error.message }); + } + }); + + const showAdvanced = advanced.isOpen || getValues("nsfw") || getValues("split").length > 0; + + return ( + + + + Media post + + + + + Work in progress, this view does not work yet + + + + {getValues("media") + .filter((m) => m.file instanceof File) + .map((media) => ( + + + setValue( + "media", + getValues("media").map((m) => (m.id === media.id ? { ...media, alt } : m)), + setOptions, + ) + } + onRemove={() => + setValue( + "media", + getValues("media").filter((m) => m.id !== media.id), + setOptions, + ) + } + /> + + ))} + + setValue("media", [...files.map((file) => ({ id: nanoid(6), file })), ...getValues("media")], setOptions) + } + /> + + + setValue("content", e.target.value, setOptions)} + rows={3} + placeholder="Add a caption" + /> + + {showAdvanced && ( + <> + + + Advanced + + + + NSFW + {getValues().nsfw && ( + + )} + + + setValue("split", splits, setOptions)} + authorPubkey={account?.pubkey} + /> + + )} + + + {!showAdvanced && ( + + )} + + {formState.isDirty && ( + + )} + + + + ); +} diff --git a/src/views/new/media/media-slide.tsx b/src/views/new/media/media-slide.tsx new file mode 100644 index 000000000..e74249e66 --- /dev/null +++ b/src/views/new/media/media-slide.tsx @@ -0,0 +1,58 @@ +import { ReactNode } from "react"; +import { Box, Button, Flex, FlexProps, IconButton, Image, Input } from "@chakra-ui/react"; + +import useObjectURL from "../../../hooks/use-object-url"; +import { CloseIcon } from "@chakra-ui/icons"; + +function ImagePreview({ file }: { file: File }) { + const url = useObjectURL(file); + + return ; +} +function VideoPreview({ file }: { file: File }) { + const url = useObjectURL(file); + + // TODO: make better + return ; +} + +export type MediaSlideProps = Omit & { + file: File; + alt?: string; + onChange?: (alt: string) => void; + onRemove?: () => void; +}; + +export default function MediaSlide({ file, onChange, alt, onRemove, ...props }: MediaSlideProps) { + let preview: ReactNode; + if (file.type.includes("image/")) preview = ; + else if (file.type.includes("video/")) preview = ; + else preview = null; + + return ( + + + {preview} + + + {alt !== undefined ? ( + onChange?.(e.target.value)} placeholder="Alt text for media" p="2" /> + ) : ( + + )} + } colorScheme="red" variant="ghost" onClick={onRemove} /> + + + ); +} diff --git a/src/views/new/media/new-media-slide.tsx b/src/views/new/media/new-media-slide.tsx new file mode 100644 index 000000000..1eb17fd9e --- /dev/null +++ b/src/views/new/media/new-media-slide.tsx @@ -0,0 +1,50 @@ +import { Flex, Text, VisuallyHiddenInput } from "@chakra-ui/react"; +import { ChangeEventHandler, useMemo, useRef } from "react"; +import Plus from "../../../components/icons/plus"; + +export default function NewMediaSlide({ onSelect }: { onSelect: (files: File[]) => void }) { + const input = useRef(null); + + const handleChange: ChangeEventHandler = (event) => { + event.preventDefault(); + + const files = event.target.files; + if (files && files.length > 0) { + onSelect(Array.from(files)); + } + }; + + return ( + <> + + + + Add media + + + + + + ); +} diff --git a/src/views/new/note/index.tsx b/src/views/new/note/index.tsx new file mode 100644 index 000000000..365d082ad --- /dev/null +++ b/src/views/new/note/index.tsx @@ -0,0 +1,17 @@ +import { Flex, Heading } from "@chakra-ui/react"; +import VerticalPageLayout from "../../../components/vertical-page-layout"; +import ShortTextNoteForm from "./short-text-form"; +import BackButton from "../../../components/router/back-button"; + +export default function NewNoteView() { + return ( + + + + New note + + + + + ); +} diff --git a/src/components/post-modal/insert-image-button.tsx b/src/views/new/note/insert-image-button.tsx similarity index 85% rename from src/components/post-modal/insert-image-button.tsx rename to src/views/new/note/insert-image-button.tsx index 68b8170e8..e55564722 100644 --- a/src/components/post-modal/insert-image-button.tsx +++ b/src/views/new/note/insert-image-button.tsx @@ -1,8 +1,8 @@ import { useRef } from "react"; import { IconButton, IconButtonProps, VisuallyHiddenInput } from "@chakra-ui/react"; -import { UploadImageIcon } from "../icons"; -import useTextAreaUploadFile from "../../hooks/use-textarea-upload-file"; +import { UploadImageIcon } from "../../../components/icons"; +import useTextAreaUploadFile from "../../../hooks/use-textarea-upload-file"; export default function InsertImageButton({ onUploaded, diff --git a/src/views/new/note/short-text-form.tsx b/src/views/new/note/short-text-form.tsx new file mode 100644 index 000000000..75e2b0982 --- /dev/null +++ b/src/views/new/note/short-text-form.tsx @@ -0,0 +1,310 @@ +import { useRef, useState } from "react"; +import { + Flex, + Button, + Box, + Heading, + useDisclosure, + Input, + Switch, + ModalProps, + FormLabel, + FormControl, + FormHelperText, + Link, + Slider, + SliderTrack, + SliderFilledTrack, + SliderThumb, + ModalCloseButton, + Alert, + AlertIcon, + ButtonGroup, + Text, + FlexProps, +} from "@chakra-ui/react"; +import { useForm } from "react-hook-form"; +import { UnsignedEvent } from "nostr-tools"; +import { useAsync, useThrottle } from "react-use"; +import { useEventFactory, useObservable } from "applesauce-react/hooks"; +import { Emoji, ZapSplit } from "applesauce-core/helpers"; + +import { useFinalizeDraft, usePublishEvent } from "../../../providers/global/publish-provider"; +import useCurrentAccount from "../../../hooks/use-current-account"; +import useAppSettings from "../../../hooks/use-app-settings"; +import localSettings from "../../../services/local-settings"; +import useLocalStorageDisclosure from "../../../hooks/use-localstorage-disclosure"; +import PublishAction from "../../../classes/nostr-publish-action"; +import { useContextEmojis } from "../../../providers/global/emoji-provider"; +import useCacheForm from "../../../hooks/use-cache-form"; +import MagicTextArea, { RefType } from "../../../components/magic-textarea"; +import useTextAreaUploadFile, { useTextAreaInsertTextWithForm } from "../../../hooks/use-textarea-upload-file"; +import { ErrorBoundary } from "../../../components/error-boundary"; +import { TrustProvider } from "../../../providers/local/trust-provider"; +import TextNoteContents from "../../../components/note/timeline-note/text-note-contents"; +import InsertImageButton from "./insert-image-button"; +import InsertGifButton from "../../../components/gif/insert-gif-button"; +import { ChevronDownIcon, ChevronUpIcon } from "../../../components/icons"; +import ZapSplitCreator, { Split } from "./zap-split-creator"; +import MinePOW from "../../../components/pow/mine-pow"; +import { PublishDetails } from "../../task-manager/publish-log/publish-details"; +import CommunitySelect from "../../../components/post-modal/community-select"; + +type FormValues = { + content: string; + nsfw: boolean; + nsfwReason: string; + community: string; + split: Split[]; + difficulty: number; +}; + +export type ShortTextNoteFormProps = { + cacheFormKey?: string | null; + initContent?: string; + initCommunity?: string; +}; + +export default function ShortTextNoteForm({ + cacheFormKey = "new-note", + initContent = "", + initCommunity = "", +}: Omit & ShortTextNoteFormProps) { + const publish = usePublishEvent(); + const finalizeDraft = useFinalizeDraft(); + const account = useCurrentAccount()!; + const { noteDifficulty } = useAppSettings(); + const addClientTag = useObservable(localSettings.addClientTag); + const promptAddClientTag = useLocalStorageDisclosure("prompt-add-client-tag", true); + const [miningTarget, setMiningTarget] = useState(0); + const [publishAction, setPublishAction] = useState(); + const emojis = useContextEmojis(); + const advanced = useDisclosure(); + + const factory = useEventFactory(); + const [draft, setDraft] = useState(); + const { getValues, setValue, watch, register, handleSubmit, formState, reset } = useForm({ + defaultValues: { + content: initContent, + nsfw: false, + nsfwReason: "", + community: initCommunity, + split: [] as Split[], + difficulty: noteDifficulty || 0, + }, + mode: "all", + }); + + // watch form state + formState.isDirty; + watch("content"); + watch("nsfw"); + watch("nsfwReason"); + watch("split"); + watch("difficulty"); + + // cache form to localStorage + useCacheForm(cacheFormKey, getValues, reset, formState); + + const getDraft = async (values = getValues()) => { + // build draft using factory + let draft = await factory.note(values.content, { + emojis: emojis.filter((e) => !!e.url) as Emoji[], + contentWarning: values.nsfw ? values.nsfwReason || values.nsfw : false, + splits: values.split, + }); + + // TODO: remove when NIP-72 communities are removed + if (values.community) draft.tags.push(["a", values.community]); + + const unsigned = await finalizeDraft(draft); + + setDraft(unsigned); + return unsigned; + }; + + // throttle update the draft every 500ms + const throttleValues = useThrottle(getValues(), 500); + const { value: preview } = useAsync(() => getDraft(), [throttleValues]); + + const textAreaRef = useRef(null); + const insertText = useTextAreaInsertTextWithForm(textAreaRef, getValues, setValue); + const { onPaste } = useTextAreaUploadFile(insertText); + + const publishPost = async (unsigned?: UnsignedEvent) => { + unsigned = unsigned || draft || (await getDraft()); + + const pub = await publish("Post", unsigned); + if (pub) setPublishAction(pub); + }; + const submit = handleSubmit(async (values) => { + if (values.difficulty > 0) { + setMiningTarget(values.difficulty); + } else { + const unsigned = await getDraft(values); + publishPost(unsigned); + } + }); + + const canSubmit = getValues().content.length > 0; + + const renderBody = () => { + if (publishAction) { + return ( + + + + ); + } + + if (miningTarget && draft) { + return ( + + setMiningTarget(0)} + onSkip={publishPost} + onComplete={publishPost} + /> + + ); + } + + const showAdvanced = + advanced.isOpen || formState.dirtyFields.difficulty || formState.dirtyFields.nsfw || formState.dirtyFields.split; + + // TODO: wrap this in a form + return ( + <> + + setValue("content", e.target.value, { shouldDirty: true, shouldTouch: true })} + rows={8} + isRequired + instanceRef={(inst) => (textAreaRef.current = inst)} + onPaste={onPaste} + onKeyDown={(e) => { + if ((e.ctrlKey || e.metaKey) && e.key === "Enter") submit(); + }} + /> + {preview && preview.content.length > 0 && ( + + Preview: + + + + + + + + + )} + + + + + + + + + {formState.isDirty && ( + + )} + + + {showAdvanced && ( + + + + Post to community + + + + NSFW + {getValues().nsfw && ( + + )} + + + POW Difficulty ({getValues("difficulty")}) + setValue("difficulty", v, { shouldDirty: true, shouldTouch: true })} + min={0} + max={40} + step={1} + > + + + + + + + The number of leading 0's in the event id. see{" "} + + NIP-13 + + + + + + setValue("split", splits, { shouldDirty: true, shouldTouch: true })} + authorPubkey={account?.pubkey} + /> + + + )} + + + {!addClientTag && promptAddClientTag.isOpen && ( + + + + Enable{" "} + + NIP-89 + {" "} + client tags and let other users know what app you're using to write notes + + + + + + + )} + + ); + }; + + return ( + <> + {publishAction && } + {renderBody()} + + ); +} diff --git a/src/components/post-modal/zap-split-creator.tsx b/src/views/new/note/zap-split-creator.tsx similarity index 84% rename from src/components/post-modal/zap-split-creator.tsx rename to src/views/new/note/zap-split-creator.tsx index 1e030c80b..877bda1a7 100644 --- a/src/components/post-modal/zap-split-creator.tsx +++ b/src/views/new/note/zap-split-creator.tsx @@ -12,13 +12,13 @@ import { import { CloseIcon } from "@chakra-ui/icons"; import { useForm } from "react-hook-form"; -import { AddIcon } from "../icons"; -import { normalizeToHexPubkey } from "../../helpers/nip19"; -import UserAvatar from "../user/user-avatar"; -import UserLink from "../user/user-link"; -import UserAutocomplete from "../user-autocomplete"; +import { AddIcon } from "../../../components/icons"; +import { normalizeToHexPubkey } from "../../../helpers/nip19"; +import UserAvatar from "../../../components/user/user-avatar"; +import UserLink from "../../../components/user/user-link"; +import UserAutocomplete from "../../../components/user-autocomplete"; -type Split = { pubkey: string; weight: number }; +export type Split = { pubkey: string; weight: number }; function validateNpub(input: string) { const pubkey = normalizeToHexPubkey(input); @@ -48,9 +48,12 @@ function AddUserForm({ onSubmit }: { onSubmit: (values: Split) => void }) { }); return ( - - - } aria-label="Add" type="submit" /> + + + } aria-label="Add" onClick={submit} /> ); } diff --git a/src/views/other-stuff/apps.ts b/src/views/other-stuff/apps.ts index c59e1ae0e..2c578ddc5 100644 --- a/src/views/other-stuff/apps.ts +++ b/src/views/other-stuff/apps.ts @@ -10,6 +10,7 @@ import { ListsIcon, LiveStreamIcon, MapIcon, + MediaIcon, MuteIcon, SearchIcon, TorrentIcon, @@ -23,7 +24,6 @@ import MessageQuestionSquare from "../../components/icons/message-question-squar import UploadCloud01 from "../../components/icons/upload-cloud-01"; import Edit04 from "../../components/icons/edit-04"; import Users03 from "../../components/icons/users-03"; -import Camera01 from "../../components/icons/camera-01"; export const internalApps: App[] = [ { @@ -36,7 +36,7 @@ export const internalApps: App[] = [ { title: "Media", description: "Browser media posts", - icon: Camera01, + icon: MediaIcon, id: "media", to: "/media", }, diff --git a/src/views/support/components/support-form.tsx b/src/views/support/components/support-form.tsx index 5330a7b83..b3308bf6e 100644 --- a/src/views/support/components/support-form.tsx +++ b/src/views/support/components/support-form.tsx @@ -7,7 +7,7 @@ import { unixNow } from "applesauce-core/helpers"; import MagicTextArea, { RefType } from "../../../components/magic-textarea"; import useTextAreaUploadFile, { useTextAreaInsertTextWithForm } from "../../../hooks/use-textarea-upload-file"; import { LightningIcon } from "../../../components/icons"; -import InsertImageButton from "../../../components/post-modal/insert-image-button"; +import InsertImageButton from "../../new/note/insert-image-button"; import InsertGifButton from "../../../components/gif/insert-gif-button"; import TextNoteContents from "../../../components/note/timeline-note/text-note-contents"; import { TrustProvider } from "../../../providers/local/trust-provider"; diff --git a/src/views/thread/components/reply-form.tsx b/src/views/thread/components/reply-form.tsx index dbf526ad4..d54980ecd 100644 --- a/src/views/thread/components/reply-form.tsx +++ b/src/views/thread/components/reply-form.tsx @@ -16,7 +16,7 @@ import { TextNoteContents } from "../../../components/note/timeline-note/text-no import useCacheForm from "../../../hooks/use-cache-form"; import useTextAreaUploadFile, { useTextAreaInsertTextWithForm } from "../../../hooks/use-textarea-upload-file"; import InsertGifButton from "../../../components/gif/insert-gif-button"; -import InsertImageButton from "../../../components/post-modal/insert-image-button"; +import InsertImageButton from "../../new/note/insert-image-button"; export type ReplyFormProps = { item: ThreadItem; diff --git a/src/views/wiki/components/markdown-editor.tsx b/src/views/wiki/components/markdown-editor.tsx index 8d8ac6d48..4cd649786 100644 --- a/src/views/wiki/components/markdown-editor.tsx +++ b/src/views/wiki/components/markdown-editor.tsx @@ -1,7 +1,6 @@ import { useMemo, useRef, useState } from "react"; import { VisuallyHidden } from "@chakra-ui/react"; import SimpleMDE, { SimpleMDEReactProps } from "react-simplemde-editor"; -import { multiServerUpload } from "blossom-client-sdk/actions/upload"; import ReactDOMServer from "react-dom/server"; import { Global, css } from "@emotion/react";