added WIP media post view

This commit is contained in:
hzrd149 2025-01-05 13:17:55 -06:00
parent b3f91e8751
commit e8b7ecafe3
23 changed files with 726 additions and 153 deletions

View File

@ -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",

86
pnpm-lock.yaml generated
View File

@ -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

View File

@ -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: <RootPage />,
children: [
{
path: "new",
children: [
{ path: "", element: <NewView /> },
{ path: "note", element: <NewNoteView /> },
{ path: "media", element: <NewMediaPostView /> },
],
},
{
path: "/u/:pubkey",
element: <UserView />,

View File

@ -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

View File

@ -61,7 +61,6 @@ export default function AccountSwitcher() {
borderWidth={1}
display="flex"
gap="2"
mb="2"
alignItems="center"
flexGrow={1}
onClick={onToggle}

View File

@ -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<FlexProps, "children">) {
<>
<AccountSwitcher />
<Button
leftIcon={<WritingIcon boxSize={6} />}
aria-label="Write Note"
title="Write Note"
onClick={() => openModal()}
as={RouterLink}
leftIcon={<Plus boxSize={6} />}
aria-label="Create new"
title="Create new"
colorScheme="primary"
size="lg"
isDisabled={account.readonly}
to="/new"
>
Write Note
New
</Button>
</>
)}

View File

@ -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<FlexProps, "children">) {
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<FlexProps, "children">) {
) : (
<Avatar size="sm" src="/apple-touch-icon.png" onClick={onOpen} cursor="pointer" />
)}
<IconButton as={RouterLink} icon={<NotesIcon boxSize={6} />} aria-label="Home" flexGrow="1" size="md" to="/" />
<IconButton
icon={<NotesIcon boxSize={6} />}
aria-label="Home"
onClick={() => navigate("/")}
flexGrow="1"
size="md"
/>
<IconButton
as={RouterLink}
icon={<SearchIcon boxSize={6} />}
aria-label="Search"
onClick={() => navigate(`/search`)}
flexGrow="1"
size="md"
to="/search"
/>
<IconButton
as={RouterLink}
icon={<PlusCircleIcon boxSize={6} />}
aria-label="New Note"
onClick={() => {
openModal();
}}
aria-label="Create new"
title="Create new"
variant="solid"
colorScheme="primary"
isDisabled={account?.readonly ?? true}
to="/new"
/>
<IconButton
as={RouterLink}
icon={<DirectMessagesIcon boxSize={6} />}
aria-label="Messages"
onClick={() => navigate(`/dm`)}
flexGrow="1"
size="md"
to="/dm"
/>
<IconButton
as={RouterLink}
icon={<NotificationsIcon boxSize={6} />}
aria-label="Notifications"
onClick={() => navigate("/notifications")}
flexGrow="1"
size="md"
to="/notifications"
/>
<IconButton
icon={<Rocket02 boxSize={6} />}
aria-label="Launchpad"
onClick={() => navigate("/launchpad")}
isDisabled={account?.readonly ?? true}
/>
<IconButton as={RouterLink} icon={<Rocket02 boxSize={6} />} aria-label="Launchpad" to="/launchpad" />
</Flex>
<MobileSideDrawer isOpen={isOpen} onClose={onClose} />
</>

View File

@ -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;

View File

@ -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<T extends ServerType = ServerType>(
servers: T[],
@ -8,8 +8,11 @@ export async function simpleMultiServerUpload<T extends ServerType = ServerType>
opts?: MultiServerUploadOptions<T, File>,
): Promise<BlobDescriptor> {
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;

View File

@ -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,

View File

@ -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 (
<VerticalPageLayout gap="4" direction="row" wrap="wrap">
<Flex justifyContent="space-between" w="full">
<AccountSwitcher />
<Flex gap="2">
<AccountSwitcher />
<Button
as={RouterLink}
colorScheme="primary"
size="lg"
to="/new"
variant="outline"
leftIcon={<Plus boxSize={6} />}
>
New
<KeyboardShortcut letter="n" ml="2" />
</Button>
</Flex>
<IconButton
as={RouterLink}
icon={<SettingsIcon boxSize={6} />}
aria-label="Settings"
title="Settings"
size="lg"
borderRadius="50%"
to="/settings"
/>
</Flex>
<Flex gap="4" w="full">
<Button colorScheme="primary" size="lg" onClick={() => openModal()} variant="outline">
New Note
<KeyboardShortcut letter="n" ml="2" onPress={(e) => openModal()} />
</Button>
<SearchForm flex={1} />
</Flex>
<SearchForm flex={1} />
<ErrorBoundary>
<FeedsCard w="full" />

37
src/views/new/index.tsx Normal file
View File

@ -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 (
<VerticalPageLayout>
<Heading size="lg">Create new...</Heading>
<SimpleGrid columns={{ base: 1, lg: 2 }} gap="2">
{NEW_TYPES.map(({ title, path, icon: Icon, summary }) => (
<Card as={LinkBox}>
<CardBody display="flex" gap="4">
<Icon boxSize={10} />
<Flex gap="2" flexDirection="column">
<Heading size="md">
<HoverLinkOverlay as={RouterLink} to={path}>
{title}
</HoverLinkOverlay>
</Heading>
{summary && <Text whiteSpace="pre-line">{summary}</Text>}
</Flex>
</CardBody>
</Card>
))}
</SimpleGrid>
</VerticalPageLayout>
);
}

View File

@ -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<FormValues>({
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 (
<VerticalPageLayout as="form" onSubmit={submit} mx="auto" maxW="4xl" w="full">
<Flex gap="2">
<BackButton />
<Heading>Media post</Heading>
</Flex>
<Alert status="info" flexShrink={0}>
<AlertIcon />
Work in progress, this view does not work yet
</Alert>
<Flex overflowY="hidden" overflowX="scroll" position="relative" h="md" flexShrink={0} gap="2" pb="2">
{getValues("media")
.filter((m) => m.file instanceof File)
.map((media) => (
<ErrorBoundary key={media.id}>
<MediaSlide
alt={media.alt}
file={media.file}
onChange={(alt) =>
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,
)
}
/>
</ErrorBoundary>
))}
<NewMediaSlide
onSelect={(files) =>
setValue("media", [...files.map((file) => ({ id: nanoid(6), file })), ...getValues("media")], setOptions)
}
/>
</Flex>
<MagicTextArea
value={getValues().content}
onChange={(e) => setValue("content", e.target.value, setOptions)}
rows={3}
placeholder="Add a caption"
/>
{showAdvanced && (
<>
<Flex gap="2" alignItems="center" mt="2">
<Divider />
<Heading size="sm">Advanced</Heading>
<Divider />
</Flex>
<Flex gap="2" direction="column">
<Switch {...register("nsfw")}>NSFW</Switch>
{getValues().nsfw && (
<Input {...register("nsfwReason", { required: true })} placeholder="NSFW Reason" isRequired />
)}
</Flex>
<ZapSplitCreator
splits={getValues("split")}
onChange={(splits) => setValue("split", splits, setOptions)}
authorPubkey={account?.pubkey}
/>
</>
)}
<Flex gap="2">
{!showAdvanced && (
<Button variant="link" p="2" onClick={advanced.onOpen}>
Show advanced
</Button>
)}
<Spacer />
{formState.isDirty && (
<Button variant="ghost" onClick={() => confirm("Clear draft?") && reset()}>
Clear
</Button>
)}
<Button type="submit" colorScheme="primary">
Preview
</Button>
</Flex>
</VerticalPageLayout>
);
}

View File

@ -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 <Image src={url} w="auto" h="auto" maxW="full" maxH="full" />;
}
function VideoPreview({ file }: { file: File }) {
const url = useObjectURL(file);
// TODO: make better
return <Box as="video" src={url} w="auto" h="auto" maxW="full" maxH="full" />;
}
export type MediaSlideProps = Omit<FlexProps, "children" | "onChange"> & {
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 = <ImagePreview file={file} />;
else if (file.type.includes("video/")) preview = <VideoPreview file={file} />;
else preview = null;
return (
<Flex
h="full"
maxW="min(100%, var(--chakra-sizes-lg))"
direction="column"
gap="2"
flexShrink={0}
overflow="hidden"
alignItems="center"
{...props}
>
<Flex overflow="hidden" alignItems="center" justifyContent="center" position="relative" w="max-content">
{preview}
</Flex>
<Flex gap="2" w="full" justifyContent="flex-end">
{alt !== undefined ? (
<Input value={alt} onChange={(e) => onChange?.(e.target.value)} placeholder="Alt text for media" p="2" />
) : (
<Button variant="link" onClick={() => onChange?.("")}>
add alt text
</Button>
)}
<IconButton aria-label="Remove" icon={<CloseIcon />} colorScheme="red" variant="ghost" onClick={onRemove} />
</Flex>
</Flex>
);
}

View File

@ -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<HTMLInputElement | null>(null);
const handleChange: ChangeEventHandler<HTMLInputElement> = (event) => {
event.preventDefault();
const files = event.target.files;
if (files && files.length > 0) {
onSelect(Array.from(files));
}
};
return (
<>
<Flex
as="label"
htmlFor="select-media"
direction="column"
gap="2"
w="full"
flexShrink={0}
h="full"
maxW="md"
borderWidth="1px"
rounded="md"
alignItems="center"
justifyContent="center"
cursor="pointer"
>
<Plus boxSize={10} />
<Text fontWeight="bold" fontSize="xl">
Add media
</Text>
</Flex>
<VisuallyHiddenInput
id="select-media"
type="file"
accept="image/*"
multiple
ref={input}
onChange={handleChange}
/>
</>
);
}

View File

@ -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 (
<VerticalPageLayout mx="auto" maxW="4xl" w="full">
<Flex gap="2" alignItems="center">
<BackButton />
<Heading>New note</Heading>
</Flex>
<ShortTextNoteForm />
</VerticalPageLayout>
);
}

View File

@ -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,

View File

@ -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<FlexProps, "children"> & 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<PublishAction>();
const emojis = useContextEmojis();
const advanced = useDisclosure();
const factory = useEventFactory();
const [draft, setDraft] = useState<UnsignedEvent>();
const { getValues, setValue, watch, register, handleSubmit, formState, reset } = useForm<FormValues>({
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<FormValues>(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<RefType | null>(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 (
<Flex direction="column" gap="2">
<PublishDetails pub={publishAction} />
</Flex>
);
}
if (miningTarget && draft) {
return (
<Flex direction="column" gap="2">
<MinePOW
draft={draft}
targetPOW={miningTarget}
onCancel={() => setMiningTarget(0)}
onSkip={publishPost}
onComplete={publishPost}
/>
</Flex>
);
}
const showAdvanced =
advanced.isOpen || formState.dirtyFields.difficulty || formState.dirtyFields.nsfw || formState.dirtyFields.split;
// TODO: wrap this in a form
return (
<>
<Flex direction="column" gap="2">
<MagicTextArea
autoFocus
mb="2"
value={getValues().content}
onChange={(e) => 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 && (
<Box>
<Heading size="sm">Preview:</Heading>
<Box borderWidth={1} borderRadius="md" p="2">
<ErrorBoundary>
<TrustProvider trust>
<TextNoteContents event={preview} />
</TrustProvider>
</ErrorBoundary>
</Box>
</Box>
)}
<Flex gap="2" alignItems="center" justifyContent="flex-end">
<Flex mr="auto" gap="2">
<InsertImageButton onUploaded={insertText} aria-label="Upload image" />
<InsertGifButton onSelectURL={insertText} aria-label="Add gif" />
</Flex>
</Flex>
<Flex gap="2" alignItems="center" justifyContent="space-between">
<Button
variant="link"
rightIcon={advanced.isOpen ? <ChevronUpIcon /> : <ChevronDownIcon />}
onClick={advanced.onToggle}
>
More Options
</Button>
{formState.isDirty && (
<Button variant="ghost" onClick={() => confirm("Clear draft?") && reset()} ms="auto">
Clear
</Button>
)}
<Button
colorScheme="primary"
type="submit"
isLoading={formState.isSubmitting}
onClick={submit}
isDisabled={!canSubmit}
>
Post
</Button>
</Flex>
{showAdvanced && (
<Flex direction={{ base: "column", lg: "row" }} gap="4">
<Flex direction="column" gap="2" flex={1}>
<FormControl>
<FormLabel>Post to community</FormLabel>
<CommunitySelect {...register("community")} />
</FormControl>
<Flex gap="2" direction="column">
<Switch {...register("nsfw")}>NSFW</Switch>
{getValues().nsfw && (
<Input {...register("nsfwReason", { required: true })} placeholder="Reason" isRequired />
)}
</Flex>
<FormControl>
<FormLabel>POW Difficulty ({getValues("difficulty")})</FormLabel>
<Slider
aria-label="difficulty"
value={getValues("difficulty")}
onChange={(v) => setValue("difficulty", v, { shouldDirty: true, shouldTouch: true })}
min={0}
max={40}
step={1}
>
<SliderTrack>
<SliderFilledTrack />
</SliderTrack>
<SliderThumb />
</Slider>
<FormHelperText>
The number of leading 0's in the event id. see{" "}
<Link href="https://github.com/nostr-protocol/nips/blob/master/13.md" isExternal>
NIP-13
</Link>
</FormHelperText>
</FormControl>
</Flex>
<Flex direction="column" gap="2" flex={1}>
<ZapSplitCreator
splits={getValues().split}
onChange={(splits) => setValue("split", splits, { shouldDirty: true, shouldTouch: true })}
authorPubkey={account?.pubkey}
/>
</Flex>
</Flex>
)}
</Flex>
{!addClientTag && promptAddClientTag.isOpen && (
<Alert status="info" whiteSpace="pre-wrap" flexDirection={{ base: "column", lg: "row" }}>
<AlertIcon hideBelow="lg" />
<Text>
Enable{" "}
<Link isExternal href="https://github.com/nostr-protocol/nips/blob/master/89.md#client-tag">
NIP-89
</Link>{" "}
client tags and let other users know what app you're using to write notes
</Text>
<ButtonGroup ml="auto" size="sm" variant="ghost">
<Button onClick={promptAddClientTag.onClose}>Close</Button>
<Button colorScheme="primary" onClick={() => localSettings.addClientTag.next(true)}>
Enable
</Button>
</ButtonGroup>
</Alert>
)}
</>
);
};
return (
<>
{publishAction && <ModalCloseButton />}
{renderBody()}
</>
);
}

View File

@ -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 (
<Flex as="form" gap="2" onSubmit={submit}>
<UserAutocomplete {...register("pubkey", { required: true, validate: validateNpub })} />
<IconButton icon={<AddIcon boxSize={5} />} aria-label="Add" type="submit" />
<Flex gap="2">
<UserAutocomplete
placeholder="Search users"
{...register("pubkey", { required: true, validate: validateNpub })}
/>
<IconButton icon={<AddIcon boxSize={5} />} aria-label="Add" onClick={submit} />
</Flex>
);
}

View File

@ -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",
},

View File

@ -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";

View File

@ -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;

View File

@ -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";