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) {
<>
}
- aria-label="Write Note"
- title="Write Note"
- onClick={() => openModal()}
+ as={RouterLink}
+ leftIcon={}
+ aria-label="Create new"
+ title="Create new"
colorScheme="primary"
size="lg"
- isDisabled={account.readonly}
+ to="/new"
>
- Write Note
+ New
>
)}
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 (
-
+
+
+ }
+ >
+ New
+
+
+
}
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:
+
+
+
+
+
+
+
+
+ )}
+
+
+
+
+
+
+
+ : }
+ onClick={advanced.onToggle}
+ >
+ More Options
+
+ {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";