diff --git a/.changeset/ninety-books-sneeze.md b/.changeset/ninety-books-sneeze.md new file mode 100644 index 000000000..a8ec019d2 --- /dev/null +++ b/.changeset/ninety-books-sneeze.md @@ -0,0 +1,5 @@ +--- +"nostrudel": minor +--- + +Add simple file views and comments diff --git a/package.json b/package.json index ccf674b3a..a27f53b83 100644 --- a/package.json +++ b/package.json @@ -65,6 +65,7 @@ "debug": "^4.4.0", "easymde": "^2.18.0", "emoji-regex": "^10.4.0", + "file-saver": "^2.0.5", "framer-motion": "^10.18.0", "gif-picker-react": "^1.4.0", "handlebars": "^4.7.8", @@ -129,6 +130,7 @@ "@types/chroma-js": "^2.4.5", "@types/debug": "^4.1.12", "@types/dom-serial": "^1.0.6", + "@types/file-saver": "^2.0.7", "@types/identicon.js": "^2.3.5", "@types/json-schema": "^7.0.15", "@types/leaflet": "^1.9.15", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1921dbdc7..29c4d5ad7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -159,6 +159,9 @@ importers: emoji-regex: specifier: ^10.4.0 version: 10.4.0 + file-saver: + specifier: ^2.0.5 + version: 2.0.5 framer-motion: specifier: ^10.18.0 version: 10.18.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -346,6 +349,9 @@ importers: '@types/dom-serial': specifier: ^1.0.6 version: 1.0.6 + '@types/file-saver': + specifier: ^2.0.7 + version: 2.0.7 '@types/identicon.js': specifier: ^2.3.5 version: 2.3.5 @@ -1728,6 +1734,9 @@ packages: '@types/estree@1.0.6': resolution: {integrity: sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==} + '@types/file-saver@2.0.7': + resolution: {integrity: sha512-dNKVfHd/jk0SkR/exKGj2ggkB45MAkzvWCaqLUUgkyjITkGNzH8H+yUwr+BLJUBjZOe9w8X3wgmXhZDRg1ED6A==} + '@types/filesystem@0.0.36': resolution: {integrity: sha512-vPDXOZuannb9FZdxgHnqSwAG/jvdGM8Wq+6N4D/d80z+D4HWH+bItqsZaVRQykAn6WEVeEkLm2oQigyHtgb0RA==} @@ -2559,6 +2568,9 @@ packages: fflate@0.6.10: resolution: {integrity: sha512-IQrh3lEPM93wVCEczc9SaAOvkmcoQn/G8Bo1e8ZPlY3X3bnAxWaBdvTdvM1hP62iZp0BXWDy4vTAy4fF0+Dlpg==} + file-saver@2.0.5: + resolution: {integrity: sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA==} + filelist@1.0.4: resolution: {integrity: sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q==} @@ -6205,6 +6217,8 @@ snapshots: '@types/estree@1.0.6': {} + '@types/file-saver@2.0.7': {} + '@types/filesystem@0.0.36': dependencies: '@types/filewriter': 0.0.33 @@ -7216,6 +7230,8 @@ snapshots: fflate@0.6.10: {} + file-saver@2.0.5: {} + filelist@1.0.4: dependencies: minimatch: 5.1.6 diff --git a/src/app.tsx b/src/app.tsx index 7b45f5e90..d293522e5 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -109,6 +109,7 @@ 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")); +const UserFilesTab = lazy(() => import("./views/user/files")); const ToolsHomeView = lazy(() => import("./views/tools")); const NetworkMuteGraphView = lazy(() => import("./views/tools/network-mute-graph")); @@ -144,6 +145,9 @@ const WikiCompareView = lazy(() => import("./views/wiki/compare")); const CreateWikiPageView = lazy(() => import("./views/wiki/create")); const EditWikiPageView = lazy(() => import("./views/wiki/edit")); +const FilesHomeView = lazy(() => import("./views/files")); +const FileDetailsView = lazy(() => import("./views/files/file")); + const PodcastsHomeView = lazy(() => import("./views/podcasts")); const PodcastView = lazy(() => import("./views/podcasts/podcast")); const EpisodeView = lazy(() => import("./views/podcasts/podcast/episode")); @@ -278,6 +282,7 @@ const router = createHashRouter([ { path: "streams", element: }, { path: "tracks", element: }, { path: "videos", element: }, + { path: "files", element: }, { path: "zaps", element: }, { path: "reactions", element: }, { path: "lists", element: }, @@ -402,6 +407,19 @@ const router = createHashRouter([ { path: ":pointer", element: }, ], }, + { + path: "files", + children: [ + { + path: "", + element: , + }, + { + path: ":nevent", + element: , + }, + ], + }, { path: "wiki", children: [ diff --git a/src/views/articles/components/article-comment-form.tsx b/src/components/comment/generic-comment-form.tsx similarity index 81% rename from src/views/articles/components/article-comment-form.tsx rename to src/components/comment/generic-comment-form.tsx index 7a4ec12a7..15f1ecf65 100644 --- a/src/views/articles/components/article-comment-form.tsx +++ b/src/components/comment/generic-comment-form.tsx @@ -5,18 +5,18 @@ import { getEventUID } from "applesauce-core/helpers"; import { useForm } from "react-hook-form"; import { useAsync, useThrottle } from "react-use"; -import { usePublishEvent } from "../../../providers/global/publish-provider"; -import { useContextEmojis } from "../../../providers/global/emoji-provider"; -import useTextAreaUploadFile, { useTextAreaInsertTextWithForm } from "../../../hooks/use-textarea-upload-file"; -import MagicTextArea, { RefType } from "../../../components/magic-textarea"; -import useCacheForm from "../../../hooks/use-cache-form"; +import { usePublishEvent } from "../../providers/global/publish-provider"; +import { useContextEmojis } from "../../providers/global/emoji-provider"; +import useTextAreaUploadFile, { useTextAreaInsertTextWithForm } from "../../hooks/use-textarea-upload-file"; +import MagicTextArea, { RefType } from "../magic-textarea"; +import useCacheForm from "../../hooks/use-cache-form"; import { Box, Button, ButtonGroup, Flex } from "@chakra-ui/react"; -import InsertImageButton from "../../new/note/insert-image-button"; -import InsertGifButton from "../../../components/gif/insert-gif-button"; -import { TrustProvider } from "../../../providers/local/trust-provider"; -import TextNoteContents from "../../../components/note/timeline-note/text-note-contents"; +import InsertImageButton from "../../views/new/note/insert-image-button"; +import InsertGifButton from "../gif/insert-gif-button"; +import { TrustProvider } from "../../providers/local/trust-provider"; +import TextNoteContents from "../note/timeline-note/text-note-contents"; -export default function ArticleCommentForm({ +export default function GenericCommentForm({ event, onSubmitted, onCancel, diff --git a/src/views/articles/components/article-comments.tsx b/src/components/comment/generic-comments.tsx similarity index 64% rename from src/views/articles/components/article-comments.tsx rename to src/components/comment/generic-comments.tsx index bfa390fab..9fea0e3c7 100644 --- a/src/views/articles/components/article-comments.tsx +++ b/src/components/comment/generic-comments.tsx @@ -1,3 +1,4 @@ +import { memo } from "react"; import { Box, Button, @@ -10,27 +11,27 @@ import { IconButton, useDisclosure, } from "@chakra-ui/react"; -import { NostrEvent } from "nostr-tools"; +import { kinds, NostrEvent } from "nostr-tools"; import { COMMENT_KIND, getEventUID } from "applesauce-core/helpers"; import { useStoreQuery } from "applesauce-react/hooks"; import { CommentsQuery, RepliesQuery } from "applesauce-core/queries"; -import Timestamp from "../../../components/timestamp"; -import DebugEventButton from "../../../components/debug-modal/debug-event-button"; -import UserLink from "../../../components/user/user-link"; -import TextNoteContents from "../../../components/note/timeline-note/text-note-contents"; -import { useReadRelays } from "../../../hooks/use-client-relays"; -import useTimelineLoader from "../../../hooks/use-timeline-loader"; -import { useTimelineCurserIntersectionCallback } from "../../../hooks/use-timeline-cursor-intersection-callback"; -import IntersectionObserverProvider from "../../../providers/local/intersection-observer"; -import UserAvatarLink from "../../../components/user/user-avatar-link"; -import UserDnsIdentity from "../../../components/user/user-dns-identity"; -import ArticleCommentForm from "./article-comment-form"; -import NoteReactions from "../../../components/note/timeline-note/components/note-reactions"; -import { ChevronDownIcon, ChevronUpIcon, ReplyIcon } from "../../../components/icons"; -import EventZapButton from "../../../components/zap/event-zap-button"; +import Timestamp from "../timestamp"; +import DebugEventButton from "../debug-modal/debug-event-button"; +import UserLink from "../user/user-link"; +import TextNoteContents from "../note/timeline-note/text-note-contents"; +import { useReadRelays } from "../../hooks/use-client-relays"; +import useTimelineLoader from "../../hooks/use-timeline-loader"; +import { useTimelineCurserIntersectionCallback } from "../../hooks/use-timeline-cursor-intersection-callback"; +import IntersectionObserverProvider from "../../providers/local/intersection-observer"; +import UserAvatarLink from "../user/user-avatar-link"; +import UserDnsIdentity from "../user/user-dns-identity"; +import NoteReactions from "../note/timeline-note/components/note-reactions"; +import { ChevronDownIcon, ChevronUpIcon, ReplyIcon } from "../icons"; +import EventZapButton from "../zap/event-zap-button"; +import GenericCommentForm from "./generic-comment-form"; -function Comment({ comment }: { comment: NostrEvent }) { +const Comment = memo(({ comment }: { comment: NostrEvent }) => { const reply = useDisclosure(); const replies = useStoreQuery(RepliesQuery, [comment]); const expand = useDisclosure({ defaultIsOpen: true }); @@ -75,7 +76,7 @@ function Comment({ comment }: { comment: NostrEvent }) { )} - {reply.isOpen && } + {reply.isOpen && } {replies && replies.length > 2 && expand.isOpen && !all.isOpen && ( ) : ( } onClick={onOpen} - aria-label="Repost Note" - title="Repost Note" + aria-label={title} + title={title} colorScheme={hasShared ? "primary" : undefined} /> )} diff --git a/src/components/note/timeline-note/index.tsx b/src/components/note/timeline-note/index.tsx index 00c09fbdf..6fc609cfe 100644 --- a/src/components/note/timeline-note/index.tsx +++ b/src/components/note/timeline-note/index.tsx @@ -23,8 +23,8 @@ import UserLink from "../../user/user-link"; import EventZapButton from "../../zap/event-zap-button"; import { ExpandProvider } from "../../../providers/local/expanded"; import EventVerificationIcon from "../../common-event/event-verification-icon"; -import ShareButton from "./components/share-button"; -import QuoteEventButton from "../quote-event-button"; +import EventShareButton from "./components/event-share-button"; +import EventQuoteButton from "../event-quote-button"; import { ReplyIcon } from "../../icons"; import NoteContentWithWarning from "./note-content-with-warning"; import { TrustProvider } from "../../../providers/local/trust-provider"; @@ -130,8 +130,8 @@ export function TimelineNote({ {showReplyButton && ( } aria-label="Reply" title="Reply" onClick={replyForm.onOpen} /> )} - - + + {!showReactionsOnNewLine && reactionButtons} diff --git a/src/components/note/timeline-note/text-note-contents.tsx b/src/components/note/timeline-note/text-note-contents.tsx index d950c7538..ab529ad85 100644 --- a/src/components/note/timeline-note/text-note-contents.tsx +++ b/src/components/note/timeline-note/text-note-contents.tsx @@ -1,4 +1,4 @@ -import React, { Suspense, useMemo } from "react"; +import React, { Suspense } from "react"; import { Box, BoxProps, Spinner } from "@chakra-ui/react"; import { EventTemplate, NostrEvent } from "nostr-tools"; import { useRenderedContent } from "applesauce-react/hooks"; diff --git a/src/views/articles/article.tsx b/src/views/articles/article.tsx index 3c78b05cf..857eb98fe 100644 --- a/src/views/articles/article.tsx +++ b/src/views/articles/article.tsx @@ -21,9 +21,9 @@ import NoteReactions from "../../components/note/timeline-note/components/note-r import EventZapButton from "../../components/zap/event-zap-button"; import ZapBubbles from "../../components/note/timeline-note/components/zap-bubbles"; import BookmarkEventButton from "../../components/note/bookmark-event"; -import QuoteEventButton from "../../components/note/quote-event-button"; -import { ArticleComments } from "./components/article-comments"; -import ArticleCommentForm from "./components/article-comment-form"; +import EventQuoteButton from "../../components/note/event-quote-button"; +import { GenericComments } from "../../components/comment/generic-comments"; +import GenericCommentForm from "../../components/comment/generic-comment-form"; import { ThreadIcon } from "../../components/icons"; function ArticlePage({ article }: { article: NostrEvent }) { @@ -55,7 +55,7 @@ function ArticlePage({ article }: { article: NostrEvent }) { - + @@ -63,20 +63,20 @@ function ArticlePage({ article }: { article: NostrEvent }) { - + {comment.isOpen ? ( - + ) : ( )} - + ); diff --git a/src/views/emoji-packs/emoji-pack.tsx b/src/views/emoji-packs/emoji-pack.tsx index adf464c3e..76cbc53b2 100644 --- a/src/views/emoji-packs/emoji-pack.tsx +++ b/src/views/emoji-packs/emoji-pack.tsx @@ -35,7 +35,7 @@ import Timestamp from "../../components/timestamp"; import useParamsAddressPointer from "../../hooks/use-params-address-pointer"; import { usePublishEvent } from "../../providers/global/publish-provider"; import EventZapButton from "../../components/zap/event-zap-button"; -import QuoteEventButton from "../../components/note/quote-event-button"; +import EventQuoteButton from "../../components/note/event-quote-button"; function AddEmojiForm({ onAdd }: { onAdd: (values: { name: string; url: string }) => void }) { const { register, handleSubmit, watch, getValues, reset } = useForm({ @@ -176,7 +176,7 @@ function EmojiPackPage({ pack }: { pack: NostrEvent }) { - + diff --git a/src/views/files/components/download-button.tsx b/src/views/files/components/download-button.tsx new file mode 100644 index 000000000..ab35278dd --- /dev/null +++ b/src/views/files/components/download-button.tsx @@ -0,0 +1,82 @@ +import { useState } from "react"; +import { Button, ButtonProps, useToast } from "@chakra-ui/react"; +import { NostrEvent } from "nostr-tools"; +import { getTagValue } from "applesauce-core/helpers"; +import { BlossomClient } from "blossom-client-sdk"; +import { saveAs } from "file-saver"; + +import useUsersMediaServers from "../../../hooks/use-user-media-servers"; +import useAsyncErrorHandler from "../../../hooks/use-async-error-handler"; +import { useSigningContext } from "../../../providers/global/signing-provider"; +import { DownloadIcon } from "../../../components/icons"; + +export default function FileDownloadButton({ + file, + children, + ...props +}: Omit & { file: NostrEvent }) { + const [loading, setLoading] = useState(false); + const toast = useToast(); + + const { servers } = useUsersMediaServers(file.pubkey); + const url = getTagValue(file, "url"); + const sha256 = getTagValue(file, "x"); + const name = getTagValue(file, "name"); + + const { requestSignature } = useSigningContext(); + + const download = async () => { + setLoading(true); + + try { + let blob: Blob | undefined = undefined; + + // download from url + if (url) + blob = await fetch(url).then( + (res) => res.blob(), + () => undefined, + ); + + // download from fallback + const fallback = file.tags.filter((t) => t[0] === "fallback").map((t) => t[1]); + if (!url && fallback.length > 0) { + for (const url of fallback) { + blob = await fetch(url).then( + (res) => res.blob(), + () => undefined, + ); + + if (blob) break; + } + } + + // attempt to download from users blossom servers + if (!blob && servers.length > 0 && sha256) { + for (const server of servers) { + blob = await BlossomClient.downloadBlob(server, sha256, { + onAuth: (server, hash) => BlossomClient.createGetAuth(requestSignature, hash), + }).then( + (res) => res.blob(), + () => undefined, + ); + + if (blob) break; + } + } + + if (blob) await saveAs(blob, name || sha256 || "download"); + else throw new Error("Failed to download file"); + } catch (error) { + if (error instanceof Error) toast({ status: "error", description: error.message }); + } + + setLoading(false); + }; + + return ( + + ); +} diff --git a/src/views/files/components/file-menu.tsx b/src/views/files/components/file-menu.tsx new file mode 100644 index 000000000..27f61b54f --- /dev/null +++ b/src/views/files/components/file-menu.tsx @@ -0,0 +1,36 @@ +import { useCallback } from "react"; +import { MenuItem } from "@chakra-ui/react"; +import { NostrEvent } from "nostr-tools"; + +import { usePublishEvent } from "../../../providers/global/publish-provider"; +import { DotsMenuButton, MenuIconButtonProps } from "../../../components/dots-menu-button"; +import OpenInAppMenuItem from "../../../components/common-menu-items/open-in-app"; +import ShareLinkMenuItem from "../../../components/common-menu-items/share-link"; +import CopyEmbedCodeMenuItem from "../../../components/common-menu-items/copy-embed-code"; +import DeleteEventMenuItem from "../../../components/common-menu-items/delete-event"; +import { BroadcastEventIcon } from "../../../components/icons"; +import DebugEventMenuItem from "../../../components/debug-modal/debug-event-menu-item"; + +export default function FileMenu({ file, ...props }: { file: NostrEvent } & Omit) { + const publish = usePublishEvent(); + + const broadcast = useCallback(async () => { + await publish("Broadcast", file); + }, []); + + return ( + <> + + + + + + + }> + Broadcast + + + + + ); +} diff --git a/src/views/files/file/index.tsx b/src/views/files/file/index.tsx new file mode 100644 index 000000000..328f11281 --- /dev/null +++ b/src/views/files/file/index.tsx @@ -0,0 +1,137 @@ +import { + Box, + Button, + ButtonGroup, + Code, + Divider, + Flex, + Heading, + Link, + Spacer, + Spinner, + Text, + useDisclosure, +} from "@chakra-ui/react"; +import { NostrEvent } from "nostr-tools"; +import { getTagValue } from "applesauce-core/helpers"; + +import VerticalPageLayout from "../../../components/vertical-page-layout"; +import { ErrorBoundary } from "../../../components/error-boundary"; +import useSingleEvent from "../../../hooks/use-single-event"; +import useParamsEventPointer from "../../../hooks/use-params-event-pointer"; +import DebugEventButton from "../../../components/debug-modal/debug-event-button"; +import EventQuoteButton from "../../../components/note/event-quote-button"; +import BackButton from "../../../components/router/back-button"; +import FilePreview from "./preview"; +import { TrustProvider } from "../../../providers/local/trust-provider"; +import GenericCommentForm from "../../../components/comment/generic-comment-form"; +import { GenericComments } from "../../../components/comment/generic-comments"; +import { ThreadIcon } from "../../../components/icons"; +import Magnet from "../../../components/icons/magnet"; +import FileDownloadButton from "../components/download-button"; +import EventZapButton from "../../../components/zap/event-zap-button"; +import NoteReactions from "../../../components/note/timeline-note/components/note-reactions"; +import FileMenu from "../components/file-menu"; +import EventShareButton from "../../../components/note/timeline-note/components/event-share-button"; +import { formatBytes } from "../../../helpers/number"; + +function FileDetailsPage({ file }: { file: NostrEvent }) { + const name = getTagValue(file, "name") || getTagValue(file, "x"); + const summary = getTagValue(file, "summary"); + const magnet = getTagValue(file, "magnet"); + const type = getTagValue(file, "m"); + const size = getTagValue(file, "size"); + const sha256 = getTagValue(file, "x"); + const comment = useDisclosure(); + + return ( + + + + + {name} + + + + + + + + + + + + + + + + + + {type && {type}} + {size && {formatBytes(parseInt(size))}} + + + {sha256 && ( + + SHA-256 hash: + + {sha256} + + + )} + + + {summary && {summary}} + + + + + + + + + {magnet && ( + + )} + + + + + + {comment.isOpen ? ( + + ) : ( + + )} + + + + + ); +} + +export default function FileDetailsView() { + const pointer = useParamsEventPointer("nevent"); + const file = useSingleEvent(pointer?.id, pointer?.relays ?? []); + + if (!file) return ; + + return ( + + + + ); +} diff --git a/src/views/files/file/preview.tsx b/src/views/files/file/preview.tsx new file mode 100644 index 000000000..80ca43744 --- /dev/null +++ b/src/views/files/file/preview.tsx @@ -0,0 +1,47 @@ +import { Alert, AlertDescription, AlertIcon, AlertTitle } from "@chakra-ui/react"; +import { getTagValue } from "applesauce-core/helpers"; +import { NostrEvent } from "nostr-tools"; + +import { TrustImage, TrustVideo } from "../../../components/content/links"; +import useUsersMediaServers from "../../../hooks/use-user-media-servers"; +import STLViewer from "../../../components/stl-viewer"; +import FileDownloadButton from "../components/download-button"; + +export default function FilePreview({ file }: { file: NostrEvent }) { + const type = getTagValue(file, "m"); + const sha256 = getTagValue(file, "x"); + const { servers } = useUsersMediaServers(file.pubkey); + + let url = getTagValue(file, "url"); + if (!url && servers && sha256 && servers.length > 0) url = new URL(sha256, servers[0]).toString(); + + if (url) { + if (type?.startsWith("image/")) return ; + if (type?.startsWith("video/")) return ; + + if (type === "model/stl") + return ; + } + + const image = getTagValue(file, "image"); + if (image) return ; + + return ( + + + + No preview! + + There is no preview for {type} files + + + ); +} diff --git a/src/views/files/index.tsx b/src/views/files/index.tsx index 15e435122..d0a7a79d5 100644 --- a/src/views/files/index.tsx +++ b/src/views/files/index.tsx @@ -1,5 +1,21 @@ -import { useState } from "react"; -import { Flex, Image, SimpleGrid, Spacer, Text } from "@chakra-ui/react"; +import { memo, useState } from "react"; +import { + Flex, + Image, + Link, + SimpleGrid, + Spacer, + Table, + TableContainer, + Tbody, + Td, + Text, + Th, + Thead, + Tr, +} from "@chakra-ui/react"; +import { Link as RouterLink } from "react-router-dom"; +import { getTagValue } from "applesauce-core/helpers"; import useTimelineLoader from "../../hooks/use-timeline-loader"; import { NostrEvent } from "../../types/nostr-event"; @@ -21,97 +37,48 @@ import IntersectionObserverProvider from "../../providers/local/intersection-obs import { useTimelineCurserIntersectionCallback } from "../../hooks/use-timeline-cursor-intersection-callback"; import { useReadRelays } from "../../hooks/use-client-relays"; import useEventIntersectionRef from "../../hooks/use-event-intersection-ref"; +import { formatBytes } from "../../helpers/number"; +import UserDnsIdentityIcon from "../../components/user/user-dns-identity-icon"; +import useShareableEventAddress from "../../hooks/use-shareable-event-address"; -function ImageFile({ event }: { event: NostrEvent }) { - const parsed = parseImageFile(event); - const settings = useAppSettings(); - const { trust } = useTrustContext(); +const FileRow = memo(({ file }: { file: NostrEvent }) => { + const ref = useEventIntersectionRef(file); + const name = getTagValue(file, "name") || getTagValue(file, "summary") || "Unknown"; + const type = getTagValue(file, "m"); + const size = getTagValue(file, "size"); - const ref = useEventIntersectionRef(event); - - const shouldBlur = settings.blurImages && !trust; - - // const showImage = useDisclosure(); - // if (shouldBlur && parsed.blurhash && parsed.width && parsed.height && !showImage.isOpen) { - // const aspect = parsed.width / parsed.height; - // return ( - // - // ); - // } - - const ImageComponent = shouldBlur ? BlurredImage : Image; - return ( - - - - - - - - - - ); -} - -function VideoFile({ event }: { event: NostrEvent }) { - const url = getFileUrl(event); - - const ref = useEventIntersectionRef(event); + const nevent = useShareableEventAddress(file); return ( - - - - - - + + + + {name} + + + + + + + + + + {type} + {size && formatBytes(parseInt(size))} + + + + ); -} +}); -function FileType({ event }: { event: NostrEvent }) { - const mimeType = event.tags.find((t) => t[0] === "m" && t[1])?.[1]; - - if (!mimeType) throw new Error("Missing MIME type"); - - if (IMAGE_TYPES.includes(mimeType)) { - return ( - - - - ); - } - if (VIDEO_TYPES.includes(mimeType)) { - return ; - } - return Unknown mine type {mimeType}; -} - -function FilesPage() { +function FilesHomePage() { const { listId, filter } = usePeopleListContext(); const relays = useReadRelays(); const [selectedTypes, setSelectedTypes] = useState(IMAGE_TYPES); - const { loader, timeline: events } = useTimelineLoader( + const { loader, timeline: files } = useTimelineLoader( `${listId}-files`, relays, selectedTypes.length > 0 && !!filter ? { kinds: [FILE_KIND], "#m": selectedTypes, ...filter } : undefined, @@ -126,23 +93,35 @@ function FilesPage() { - - {events?.map((event) => ( - - - - ))} - + + + + + + + + + + + + {files.map((file) => ( + + + + ))} + +
NameTypeSizeCreated
+
); } -export default function FilesView() { +export default function FilesHomeView() { return ( - + ); } diff --git a/src/views/link/index.tsx b/src/views/link/index.tsx index 79747d9b2..713eb117f 100644 --- a/src/views/link/index.tsx +++ b/src/views/link/index.tsx @@ -9,6 +9,7 @@ import { WIKI_PAGE_KIND } from "../../helpers/nostr/wiki"; import { EmbedEvent, EmbedEventPointer } from "../../components/embed-event"; import useReplaceableEvent from "../../hooks/use-replaceable-event"; import useSingleEvent from "../../hooks/use-single-event"; +import { MEDIA_POST_KIND } from "../../helpers/nostr/media"; function LoadUnknownAddress({ pointer, link }: { pointer: nip19.AddressPointer; link: string }) { const event = useReplaceableEvent(pointer, pointer.relays); @@ -58,6 +59,8 @@ function RenderRedirect({ event, link }: { event?: NostrEvent; link: string }) { if (k === kinds.ShortTextNote) return ; if (k === kinds.LongFormArticle) return ; if (k === WIKI_PAGE_KIND) return ; + if (k === MEDIA_POST_KIND) return ; + if (k === kinds.FileMetadata) return ; if (!event && decoded.type === "naddr") return ; if (!event && decoded.type === "nevent") return ; diff --git a/src/views/media/media-post.tsx b/src/views/media/media-post.tsx index c232d7515..1fc3b96a9 100644 --- a/src/views/media/media-post.tsx +++ b/src/views/media/media-post.tsx @@ -11,8 +11,8 @@ import MediaPostSlides from "../../components/media-post/media-slides"; import MediaPostContents from "../../components/media-post/media-post-content"; import { TrustProvider } from "../../providers/local/trust-provider"; import DebugEventButton from "../../components/debug-modal/debug-event-button"; -import ShareButton from "../../components/note/timeline-note/components/share-button"; -import QuoteEventButton from "../../components/note/quote-event-button"; +import EventShareButton from "../../components/note/timeline-note/components/event-share-button"; +import EventQuoteButton from "../../components/note/event-quote-button"; import { useBreakpointValue } from "../../providers/global/breakpoint-provider"; import EventZapIconButton from "../../components/zap/event-zap-icon-button"; import AddReactionButton from "../../components/note/timeline-note/components/add-reaction-button"; @@ -30,8 +30,8 @@ function Header({ post }: { post: NostrEvent }) { - - + + diff --git a/src/views/other-stuff/apps.ts b/src/views/other-stuff/apps.ts index 1fec25169..00fb18274 100644 --- a/src/views/other-stuff/apps.ts +++ b/src/views/other-stuff/apps.ts @@ -23,7 +23,7 @@ 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 Podcast from "../../components/icons/podcast"; +import FileAttachment01 from "../../components/icons/file-attachment-01"; export const internalApps: App[] = [ { @@ -58,6 +58,7 @@ export const internalApps: App[] = [ { title: "Tracks", description: "Browse stemstr tracks", icon: TrackIcon, id: "tracks", to: "/tracks" }, { title: "Videos", description: "Browse videos", icon: VideoIcon, id: "videos", to: "/videos" }, { title: "Articles", description: "Browse articles", icon: ArticleIcon, id: "articles", to: "/articles" }, + { title: "Files", description: "Browse files", icon: FileAttachment01, id: "files", to: "/files" }, ]; export const internalTools: App[] = [ diff --git a/src/views/streams/stream/index.tsx b/src/views/streams/stream/index.tsx index 809f12c98..b6a934a60 100644 --- a/src/views/streams/stream/index.tsx +++ b/src/views/streams/stream/index.tsx @@ -45,7 +45,7 @@ import { AdditionalRelayProvider } from "../../../providers/local/additional-rel import DebugEventButton from "../../../components/debug-modal/debug-event-button"; import useParamsAddressPointer from "../../../hooks/use-params-address-pointer"; import useReplaceableEvent from "../../../hooks/use-replaceable-event"; -import QuoteEventButton from "../../../components/note/quote-event-button"; +import EventQuoteButton from "../../../components/note/event-quote-button"; import { TrustProvider } from "../../../providers/local/trust-provider"; import StreamOpenButton from "../components/stream-open-button"; import StreamFavoriteButton from "../components/stream-favorite-button"; @@ -114,7 +114,7 @@ function DesktopStreamPage({ stream }: { stream: NostrEvent }) { - + @@ -184,7 +184,7 @@ function MobileStreamPage({ stream }: { stream: NostrEvent }) { - + diff --git a/src/views/thread/components/thread-post.tsx b/src/views/thread/components/thread-post.tsx index 843a94ecd..3089adf94 100644 --- a/src/views/thread/components/thread-post.tsx +++ b/src/views/thread/components/thread-post.tsx @@ -18,8 +18,8 @@ import UserDnsIdentity from "../../../components/user/user-dns-identity"; import useAppSettings from "../../../hooks/use-user-app-settings"; import useThreadColorLevelProps from "../../../hooks/use-thread-color-level-props"; import POWIcon from "../../../components/pow/pow-icon"; -import ShareButton from "../../../components/note/timeline-note/components/share-button"; -import QuoteEventButton from "../../../components/note/quote-event-button"; +import EventShareButton from "../../../components/note/timeline-note/components/event-share-button"; +import EventQuoteButton from "../../../components/note/event-quote-button"; import EventZapButton from "../../../components/zap/event-zap-button"; import NoteProxyLink from "../../../components/note/timeline-note/components/note-proxy-link"; import BookmarkEventButton from "../../../components/note/bookmark-event"; @@ -114,8 +114,8 @@ function ThreadPost({ post, initShowReplies, focusId, level = -1 }: ThreadItemPr } /> - - + + {!showReactionsOnNewLine && reactionButtons} diff --git a/src/views/torrents/torrent.tsx b/src/views/torrents/torrent.tsx index 4a2330d79..8031334ed 100644 --- a/src/views/torrents/torrent.tsx +++ b/src/views/torrents/torrent.tsx @@ -42,7 +42,7 @@ import { getThreadReferences } from "../../helpers/nostr/event"; import MessageTextCircle01 from "../../components/icons/message-text-circle-01"; import useParamsEventPointer from "../../hooks/use-params-event-pointer"; import EventZapButton from "../../components/zap/event-zap-button"; -import QuoteEventButton from "../../components/note/quote-event-button"; +import EventQuoteButton from "../../components/note/event-quote-button"; import { TextNoteContents } from "../../components/note/timeline-note/text-note-contents"; function TorrentDetailsPage({ torrent }: { torrent: NostrEvent }) { @@ -75,7 +75,7 @@ function TorrentDetailsPage({ torrent }: { torrent: NostrEvent }) { - + diff --git a/src/views/tracks/components/track-card.tsx b/src/views/tracks/components/track-card.tsx index 2105512a8..7ba7674a4 100644 --- a/src/views/tracks/components/track-card.tsx +++ b/src/views/tracks/components/track-card.tsx @@ -12,7 +12,7 @@ import TrackDownloadButton from "./track-download-button"; import TrackPlayer from "./track-player"; import UserDnsIdentity from "../../../components/user/user-dns-identity"; import TrackMenu from "./track-menu"; -import QuoteEventButton from "../../../components/note/quote-event-button"; +import EventQuoteButton from "../../../components/note/event-quote-button"; import EventZapButton from "../../../components/zap/event-zap-button"; import useEventIntersectionRef from "../../../hooks/use-event-intersection-ref"; @@ -45,7 +45,7 @@ export default function TrackCard({ track, ...props }: { track: NostrEvent } & O - + diff --git a/src/views/user/files.tsx b/src/views/user/files.tsx new file mode 100644 index 000000000..0ad08f775 --- /dev/null +++ b/src/views/user/files.tsx @@ -0,0 +1,78 @@ +import { Link, Table, TableContainer, Tbody, Td, Th, Thead, Tr } from "@chakra-ui/react"; +import { getTagValue } from "applesauce-core/helpers"; +import { useOutletContext, Link as RouterLink } from "react-router-dom"; +import { kinds } from "nostr-tools"; + +import useTimelineLoader from "../../hooks/use-timeline-loader"; +import { useAdditionalRelayContext } from "../../providers/local/additional-relay-context"; +import { useTimelineCurserIntersectionCallback } from "../../hooks/use-timeline-cursor-intersection-callback"; +import IntersectionObserverProvider from "../../providers/local/intersection-observer"; +import VerticalPageLayout from "../../components/vertical-page-layout"; +import TimelineActionAndStatus from "../../components/timeline/timeline-action-and-status"; +import { NostrEvent } from "../../types/nostr-event"; +import Timestamp from "../../components/timestamp"; +import useEventIntersectionRef from "../../hooks/use-event-intersection-ref"; +import { formatBytes } from "../../helpers/number"; +import useShareableEventAddress from "../../hooks/use-shareable-event-address"; + +function FileRow({ file }: { file: NostrEvent }) { + const ref = useEventIntersectionRef(file); + const name = getTagValue(file, "name") || getTagValue(file, "summary") || "Unknown"; + const type = getTagValue(file, "m"); + const size = getTagValue(file, "size"); + + const nevent = useShareableEventAddress(file); + + return ( + + + + {name} + + + {type} + {size && formatBytes(parseInt(size))} + + + + + ); +} + +export default function UserFilesTab() { + const { pubkey } = useOutletContext() as { pubkey: string }; + const readRelays = useAdditionalRelayContext(); + + const { loader, timeline: files } = useTimelineLoader(pubkey + "-files", readRelays, [ + { + authors: [pubkey], + kinds: [kinds.FileMetadata], + }, + ]); + const callback = useTimelineCurserIntersectionCallback(loader); + + return ( + + + + + + + + + + + + + + {files.map((file) => ( + + ))} + +
NameTypeSizeCreated
+
+ +
+
+ ); +} diff --git a/src/views/user/index.tsx b/src/views/user/index.tsx index 2fbbfbe22..f7aa98aa9 100644 --- a/src/views/user/index.tsx +++ b/src/views/user/index.tsx @@ -54,6 +54,7 @@ const tabs = [ { label: "Goals", path: "goals" }, { label: "Tracks", path: "tracks" }, { label: "Videos", path: "videos" }, + { label: "Files", path: "files" }, { label: "Emojis", path: "emojis" }, { label: "Torrents", path: "torrents" }, { label: "Reports", path: "reports" }, diff --git a/src/views/user/messages.tsx b/src/views/user/messages.tsx index b19ba4f8d..a00040162 100644 --- a/src/views/user/messages.tsx +++ b/src/views/user/messages.tsx @@ -43,7 +43,7 @@ export default function UserMessagesTab() { const { pubkey } = useOutletContext() as { pubkey: string }; const readRelays = useAdditionalRelayContext(); - const { loader, timeline: messages } = useTimelineLoader(pubkey + "-articles", readRelays, [ + const { loader, timeline: messages } = useTimelineLoader(pubkey + "-messages", readRelays, [ { authors: [pubkey], kinds: [kinds.EncryptedDirectMessage], diff --git a/src/views/videos/video.tsx b/src/views/videos/video.tsx index e366a9905..8d944098b 100644 --- a/src/views/videos/video.tsx +++ b/src/views/videos/video.tsx @@ -27,7 +27,7 @@ import UserName from "../../components/user/user-name"; import { useBreakpointValue } from "../../providers/global/breakpoint-provider"; import SimpleBookmarkButton from "../../components/simple-bookmark-button"; import EventZapButton from "../../components/zap/event-zap-button"; -import QuoteEventButton from "../../components/note/quote-event-button"; +import EventQuoteButton from "../../components/note/event-quote-button"; function VideoRecommendations({ video }: { video: NostrEvent }) { const readRelays = useReadRelays(); @@ -68,7 +68,7 @@ function VideoDetailsPage({ video }: { video: NostrEvent }) { - + diff --git a/src/views/wiki/page.tsx b/src/views/wiki/page.tsx index 11de48ac9..dfb19d2d0 100644 --- a/src/views/wiki/page.tsx +++ b/src/views/wiki/page.tsx @@ -31,7 +31,7 @@ import { ExternalLinkIcon } from "../../components/icons"; import FileSearch01 from "../../components/icons/file-search-01"; import EventZapButton from "../../components/zap/event-zap-button"; import ZapBubbles from "../../components/note/timeline-note/components/zap-bubbles"; -import QuoteEventButton from "../../components/note/quote-event-button"; +import EventQuoteButton from "../../components/note/event-quote-button"; import WikiPageMenu from "./components/wiki-page-menu"; import EventVoteButtons from "../../components/reactions/event-vote-buttions"; import useCurrentAccount from "../../hooks/use-current-account"; @@ -119,7 +119,7 @@ export function WikiPagePage({ page }: { page: NostrEvent }) { - +