mirror of
https://github.com/hzrd149/nostrudel.git
synced 2025-03-17 13:21:44 +01:00
Add simple file views and comments
This commit is contained in:
parent
747b7e2b8a
commit
ee7a5b355f
5
.changeset/ninety-books-sneeze.md
Normal file
5
.changeset/ninety-books-sneeze.md
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
"nostrudel": minor
|
||||
---
|
||||
|
||||
Add simple file views and comments
|
@ -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",
|
||||
|
16
pnpm-lock.yaml
generated
16
pnpm-lock.yaml
generated
@ -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
|
||||
|
18
src/app.tsx
18
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: <UserStreamsTab /> },
|
||||
{ path: "tracks", element: <UserTracksTab /> },
|
||||
{ path: "videos", element: <UserVideosTab /> },
|
||||
{ path: "files", element: <UserFilesTab /> },
|
||||
{ path: "zaps", element: <UserZapsTab /> },
|
||||
{ path: "reactions", element: <UserReactionsTab /> },
|
||||
{ path: "lists", element: <UserListsTab /> },
|
||||
@ -402,6 +407,19 @@ const router = createHashRouter([
|
||||
{ path: ":pointer", element: <MediaPostView /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "files",
|
||||
children: [
|
||||
{
|
||||
path: "",
|
||||
element: <FilesHomeView />,
|
||||
},
|
||||
{
|
||||
path: ":nevent",
|
||||
element: <FileDetailsView />,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "wiki",
|
||||
children: [
|
||||
|
@ -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,
|
@ -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 }) {
|
||||
)}
|
||||
</CardFooter>
|
||||
</Card>
|
||||
{reply.isOpen && <ArticleCommentForm event={comment} onCancel={reply.onClose} onSubmitted={reply.onClose} />}
|
||||
{reply.isOpen && <GenericCommentForm event={comment} onCancel={reply.onClose} onSubmitted={reply.onClose} />}
|
||||
{replies && replies.length > 2 && expand.isOpen && !all.isOpen && (
|
||||
<Button w="full" variant="link" p="2" onClick={all.onOpen}>
|
||||
Show more replies ({replies.length - 2})
|
||||
@ -90,16 +91,25 @@ function Comment({ comment }: { comment: NostrEvent }) {
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
export function ArticleComments({ article }: { article: NostrEvent }) {
|
||||
export function GenericComments({ event }: { event: NostrEvent }) {
|
||||
const readRelays = useReadRelays();
|
||||
const { loader } = useTimelineLoader(`${getEventUID(article)}-comments`, readRelays, {
|
||||
kinds: [COMMENT_KIND],
|
||||
"#A": [getEventUID(article)],
|
||||
});
|
||||
const { loader } = useTimelineLoader(
|
||||
`${getEventUID(event)}-comments`,
|
||||
readRelays,
|
||||
kinds.isParameterizedReplaceableKind(event.kind)
|
||||
? {
|
||||
kinds: [COMMENT_KIND],
|
||||
"#A": [getEventUID(event)],
|
||||
}
|
||||
: {
|
||||
kinds: [COMMENT_KIND],
|
||||
"#E": [event.id],
|
||||
},
|
||||
);
|
||||
|
||||
const comments = useStoreQuery(CommentsQuery, [article]);
|
||||
const comments = useStoreQuery(CommentsQuery, [event]);
|
||||
const callback = useTimelineCurserIntersectionCallback(loader);
|
||||
|
||||
return (
|
@ -1,5 +1,6 @@
|
||||
import { lazy, VideoHTMLAttributes } from "react";
|
||||
import styled from "@emotion/styled";
|
||||
import { Box, BoxProps } from "@chakra-ui/react";
|
||||
|
||||
import { isStreamURL, isVideoURL } from "../../../helpers/url";
|
||||
import useAppSettings from "../../../hooks/use-user-app-settings";
|
||||
@ -8,19 +9,21 @@ import ExpandableEmbed from "../components/expandable-embed";
|
||||
const LiveVideoPlayer = lazy(() => import("../../live-video-player"));
|
||||
|
||||
const StyledVideo = styled.video`
|
||||
max-width: 30rem;
|
||||
max-height: 20rem;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
`;
|
||||
|
||||
export function TrustVideo({ src, ...props }: { src: string } & VideoHTMLAttributes<HTMLVideoElement>) {
|
||||
export function TrustVideo({
|
||||
src,
|
||||
...props
|
||||
}: { src: string } & VideoHTMLAttributes<HTMLVideoElement> & Omit<BoxProps, "children">) {
|
||||
const { blurImages } = useAppSettings();
|
||||
const { onClick, handleEvent, style } = useElementTrustBlur();
|
||||
|
||||
return (
|
||||
<StyledVideo
|
||||
<Box
|
||||
as={StyledVideo}
|
||||
src={src}
|
||||
controls
|
||||
style={blurImages ? style : undefined}
|
||||
@ -36,7 +39,7 @@ export function renderVideoUrl(match: URL) {
|
||||
|
||||
return (
|
||||
<ExpandableEmbed label="Video" url={match} hideOnDefaultOpen>
|
||||
<TrustVideo src={match.toString()} />
|
||||
<TrustVideo src={match.toString()} maxH="lg" w="auto" />
|
||||
</ExpandableEmbed>
|
||||
);
|
||||
}
|
||||
@ -46,7 +49,7 @@ export function renderStreamUrl(match: URL) {
|
||||
|
||||
return (
|
||||
<ExpandableEmbed label="Video" url={match} hideOnDefaultOpen>
|
||||
<LiveVideoPlayer stream={match.toString()} maxW="md" maxH="md" />
|
||||
<LiveVideoPlayer stream={match.toString()} maxH="lg" w="auto" />
|
||||
</ExpandableEmbed>
|
||||
);
|
||||
}
|
||||
|
57
src/components/embed-event/event-types/embedded-file.tsx
Normal file
57
src/components/embed-event/event-types/embedded-file.tsx
Normal file
@ -0,0 +1,57 @@
|
||||
import { Box, Card, CardBody, CardProps, Flex, Heading, Image, LinkBox, Text } from "@chakra-ui/react";
|
||||
import { Link as RouterLink } from "react-router-dom";
|
||||
import { getTagValue } from "applesauce-core/helpers";
|
||||
|
||||
import { getArticlePublishDate } from "../../../helpers/nostr/long-form";
|
||||
import { NostrEvent } from "../../../types/nostr-event";
|
||||
import UserAvatarLink from "../../user/user-avatar-link";
|
||||
import UserLink from "../../user/user-link";
|
||||
import Timestamp from "../../timestamp";
|
||||
import HoverLinkOverlay from "../../hover-link-overlay";
|
||||
import useShareableEventAddress from "../../../hooks/use-shareable-event-address";
|
||||
import { formatBytes } from "../../../helpers/number";
|
||||
|
||||
export default function EmbeddedFile({ file, ...props }: Omit<CardProps, "children"> & { file: NostrEvent }) {
|
||||
const name = getTagValue(file, "name");
|
||||
const image = getTagValue(file, "thumb") || getTagValue(file, "image");
|
||||
const summary = getTagValue(file, "summary") || getTagValue(file, "alt");
|
||||
const type = getTagValue(file, "m");
|
||||
const size = getTagValue(file, "size");
|
||||
|
||||
const naddr = useShareableEventAddress(file);
|
||||
|
||||
return (
|
||||
<Card as={LinkBox} size="sm" {...props}>
|
||||
{image && (
|
||||
<Box
|
||||
backgroundImage={image}
|
||||
w="full"
|
||||
aspectRatio={3 / 1}
|
||||
hideFrom="md"
|
||||
backgroundRepeat="no-repeat"
|
||||
backgroundPosition="center"
|
||||
backgroundSize="cover"
|
||||
/>
|
||||
)}
|
||||
<CardBody>
|
||||
{image && (
|
||||
<Image src={image} alt={name} maxW="3in" maxH="2in" float="right" borderRadius="md" ml="2" hideBelow="md" />
|
||||
)}
|
||||
<Flex gap="2" alignItems="center" mb="2">
|
||||
<UserAvatarLink pubkey={file.pubkey} size="sm" />
|
||||
<UserLink pubkey={file.pubkey} fontWeight="bold" isTruncated />
|
||||
<Timestamp timestamp={getArticlePublishDate(file) ?? file.created_at} />
|
||||
</Flex>
|
||||
<Heading size="md">
|
||||
<HoverLinkOverlay as={RouterLink} to={`/files/${naddr}`}>
|
||||
{name}
|
||||
</HoverLinkOverlay>
|
||||
</Heading>
|
||||
<Text>
|
||||
{type} {size && formatBytes(parseInt(size))}
|
||||
</Text>
|
||||
<Text my="2">{summary}</Text>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
}
|
@ -21,7 +21,7 @@ import Timestamp from "../../timestamp";
|
||||
import TrackStemstrButton from "../../../views/tracks/components/track-stemstr-button";
|
||||
import TrackDownloadButton from "../../../views/tracks/components/track-download-button";
|
||||
import TrackPlayer from "../../../views/tracks/components/track-player";
|
||||
import QuoteEventButton from "../../note/quote-event-button";
|
||||
import EventQuoteButton from "../../note/event-quote-button";
|
||||
import EventZapButton from "../../zap/event-zap-button";
|
||||
|
||||
// example nevent1qqst32cnyhhs7jt578u7vp3y047dduuwjquztpvwqc43f3nvg8dh28gpzamhxue69uhhyetvv9ujuum5v4khxarj9eshquq4rxdxa
|
||||
@ -53,7 +53,7 @@ export default function EmbeddedStemstrTrack({ track, ...props }: Omit<CardProps
|
||||
Comment
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<QuoteEventButton event={track} />
|
||||
<EventQuoteButton event={track} />
|
||||
<EventZapButton event={track} />
|
||||
</ButtonGroup>
|
||||
<ButtonGroup size="sm" ml="auto">
|
||||
|
@ -38,6 +38,7 @@ const EmbeddedWikiPage = lazy(() => import("./event-types/embedded-wiki-page"));
|
||||
const EmbeddedStream = lazy(() => import("./event-types/embedded-stream"));
|
||||
const EmbeddedStreamMessage = lazy(() => import("./event-types/embedded-stream-message"));
|
||||
const EmbeddedStemstrTrack = lazy(() => import("./event-types/embedded-stemstr-track"));
|
||||
const EmbeddedFile = lazy(() => import("./event-types/embedded-file"));
|
||||
|
||||
export type EmbedProps = {
|
||||
goalProps?: EmbeddedGoalOptions;
|
||||
@ -87,6 +88,8 @@ export function EmbedEvent({
|
||||
return <EmbeddedWikiPage page={event} {...cardProps} />;
|
||||
case kinds.Zap:
|
||||
return <EmbeddedZapRecept zap={event} {...cardProps} />;
|
||||
case kinds.FileMetadata:
|
||||
return <EmbeddedFile file={event} {...cardProps} />;
|
||||
case kinds.Handlerinformation:
|
||||
// if its a content DVM
|
||||
if (event.tags.some((t) => t[0] === "k" && t[1] === String(DVM_CONTENT_DISCOVERY_JOB_KIND)))
|
||||
|
@ -9,8 +9,8 @@ import DebugEventButton from "../debug-modal/debug-event-button";
|
||||
import { TrustProvider } from "../../providers/local/trust-provider";
|
||||
import EventReactionButtons from "../event-reactions/event-reactions";
|
||||
import AddReactionButton from "../note/timeline-note/components/add-reaction-button";
|
||||
import ShareButton from "../note/timeline-note/components/share-button";
|
||||
import QuoteEventButton from "../note/quote-event-button";
|
||||
import EventShareButton from "../note/timeline-note/components/event-share-button";
|
||||
import EventQuoteButton from "../note/event-quote-button";
|
||||
import MediaPostSlides from "./media-slides";
|
||||
import MediaPostContents from "./media-post-content";
|
||||
import { getSharableEventAddress } from "../../services/relay-hints";
|
||||
@ -55,8 +55,8 @@ export default function MediaPost({ post }: { post: NostrEvent }) {
|
||||
</ButtonGroup>
|
||||
|
||||
<ButtonGroup size="sm" variant="ghost" ml="auto">
|
||||
<ShareButton event={post} />
|
||||
<QuoteEventButton event={post} />
|
||||
<EventShareButton event={post} />
|
||||
<EventQuoteButton event={post} />
|
||||
<DebugEventButton event={post} variant="ghost" ml="auto" size="sm" alignSelf="flex-start" />
|
||||
</ButtonGroup>
|
||||
</CardFooter>
|
||||
|
@ -6,10 +6,10 @@ import { QuoteEventIcon } from "../icons";
|
||||
import { PostModalContext } from "../../providers/route/post-modal-provider";
|
||||
import { getSharableEventAddress } from "../../services/relay-hints";
|
||||
|
||||
export default function QuoteEventButton({
|
||||
export default function EventQuoteButton({
|
||||
event,
|
||||
"aria-label": ariaLabel,
|
||||
title = "Quote Note",
|
||||
title = "Quote Event",
|
||||
...props
|
||||
}: Omit<IconButtonProps, "children" | "onClick" | "aria-label"> & {
|
||||
event: NostrEvent;
|
@ -7,32 +7,32 @@ import useEventCount from "../../../../hooks/use-event-count";
|
||||
import useCurrentAccount from "../../../../hooks/use-current-account";
|
||||
import ShareModal from "./share-modal";
|
||||
|
||||
export default function ShareButton({ event }: { event: NostrEvent }) {
|
||||
export default function EventShareButton({ event, title = "Share Event" }: { event: NostrEvent; title?: string }) {
|
||||
const { isOpen, onClose, onOpen } = useDisclosure();
|
||||
|
||||
const account = useCurrentAccount();
|
||||
const hasShared = useEventCount(
|
||||
account ? { "#e": [event.id], kinds: [kinds.Repost, kinds.GenericRepost], authors: [account.pubkey] } : undefined,
|
||||
);
|
||||
const ShareCount = useEventCount({ "#e": [event.id], kinds: [kinds.Repost, kinds.GenericRepost] });
|
||||
const shareCount = useEventCount({ "#e": [event.id], kinds: [kinds.Repost, kinds.GenericRepost] });
|
||||
|
||||
return (
|
||||
<>
|
||||
{ShareCount !== undefined && ShareCount > 0 ? (
|
||||
{shareCount !== undefined && shareCount > 0 ? (
|
||||
<Button
|
||||
leftIcon={<RepostIcon />}
|
||||
onClick={onOpen}
|
||||
title="Repost Note"
|
||||
title={title}
|
||||
colorScheme={hasShared ? "primary" : undefined}
|
||||
>
|
||||
{ShareCount}
|
||||
{shareCount}
|
||||
</Button>
|
||||
) : (
|
||||
<IconButton
|
||||
icon={<RepostIcon />}
|
||||
onClick={onOpen}
|
||||
aria-label="Repost Note"
|
||||
title="Repost Note"
|
||||
aria-label={title}
|
||||
title={title}
|
||||
colorScheme={hasShared ? "primary" : undefined}
|
||||
/>
|
||||
)}
|
@ -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 && (
|
||||
<IconButton icon={<ReplyIcon />} aria-label="Reply" title="Reply" onClick={replyForm.onOpen} />
|
||||
)}
|
||||
<ShareButton event={event} />
|
||||
<QuoteEventButton event={event} />
|
||||
<EventShareButton event={event} />
|
||||
<EventQuoteButton event={event} />
|
||||
<EventZapButton event={event} />
|
||||
</ButtonGroup>
|
||||
{!showReactionsOnNewLine && reactionButtons}
|
||||
|
@ -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";
|
||||
|
@ -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 }) {
|
||||
<ZapBubbles event={article} mb="2" />
|
||||
<Flex gap="2">
|
||||
<EventZapButton event={article} size="sm" variant="ghost" showEventPreview={false} />
|
||||
<QuoteEventButton event={article} size="sm" variant="ghost" />
|
||||
<EventQuoteButton event={article} size="sm" variant="ghost" />
|
||||
<NoteReactions event={article} size="sm" variant="ghost" />
|
||||
</Flex>
|
||||
<Box fontSize="lg">
|
||||
@ -63,20 +63,20 @@ function ArticlePage({ article }: { article: NostrEvent }) {
|
||||
</Box>
|
||||
<Flex gap="2">
|
||||
<EventZapButton event={article} size="sm" variant="ghost" showEventPreview={false} />
|
||||
<QuoteEventButton event={article} size="sm" variant="ghost" />
|
||||
<EventQuoteButton event={article} size="sm" variant="ghost" />
|
||||
<NoteReactions event={article} size="sm" variant="ghost" />
|
||||
</Flex>
|
||||
</Box>
|
||||
<Flex mx="auto" maxW="4xl" w="full" gap="2" direction="column">
|
||||
{comment.isOpen ? (
|
||||
<ArticleCommentForm event={article} onCancel={comment.onClose} onSubmitted={comment.onClose} />
|
||||
<GenericCommentForm event={article} onCancel={comment.onClose} onSubmitted={comment.onClose} />
|
||||
) : (
|
||||
<Button leftIcon={<ThreadIcon />} onClick={comment.onOpen} mr="auto">
|
||||
Comment
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<ArticleComments article={article} />
|
||||
<GenericComments event={article} />
|
||||
</Flex>
|
||||
</VerticalPageLayout>
|
||||
);
|
||||
|
@ -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 }) {
|
||||
|
||||
<ButtonGroup variant="ghost">
|
||||
<EventZapButton event={pack} />
|
||||
<QuoteEventButton event={pack} />
|
||||
<EventQuoteButton event={pack} />
|
||||
<EmojiPackFavoriteButton pack={pack} />
|
||||
</ButtonGroup>
|
||||
|
||||
|
82
src/views/files/components/download-button.tsx
Normal file
82
src/views/files/components/download-button.tsx
Normal file
@ -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<ButtonProps, "onClick"> & { 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 (
|
||||
<Button onClick={download} leftIcon={<DownloadIcon boxSize="1.2em" />} {...props}>
|
||||
{children || "Download"}
|
||||
</Button>
|
||||
);
|
||||
}
|
36
src/views/files/components/file-menu.tsx
Normal file
36
src/views/files/components/file-menu.tsx
Normal file
@ -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<MenuIconButtonProps, "children">) {
|
||||
const publish = usePublishEvent();
|
||||
|
||||
const broadcast = useCallback(async () => {
|
||||
await publish("Broadcast", file);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<DotsMenuButton {...props}>
|
||||
<OpenInAppMenuItem event={file} />
|
||||
<ShareLinkMenuItem event={file} />
|
||||
<CopyEmbedCodeMenuItem event={file} />
|
||||
<DeleteEventMenuItem event={file} />
|
||||
|
||||
<MenuItem onClick={broadcast} icon={<BroadcastEventIcon />}>
|
||||
Broadcast
|
||||
</MenuItem>
|
||||
<DebugEventMenuItem event={file} />
|
||||
</DotsMenuButton>
|
||||
</>
|
||||
);
|
||||
}
|
137
src/views/files/file/index.tsx
Normal file
137
src/views/files/file/index.tsx
Normal file
@ -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 (
|
||||
<VerticalPageLayout>
|
||||
<Flex gap="2" alignItems="center">
|
||||
<BackButton variant="ghost" size="sm" />
|
||||
<Heading size="md" isTruncated>
|
||||
{name}
|
||||
</Heading>
|
||||
|
||||
<ButtonGroup variant="ghost" size="sm" ms="auto">
|
||||
<EventShareButton event={file} />
|
||||
<EventQuoteButton event={file} />
|
||||
<FileMenu file={file} aria-label="More options" />
|
||||
</ButtonGroup>
|
||||
</Flex>
|
||||
|
||||
<Flex
|
||||
direction="column"
|
||||
maxW="6xl"
|
||||
mx="auto"
|
||||
w="full"
|
||||
maxH="2xl"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
overflow="hidden"
|
||||
>
|
||||
<TrustProvider event={file}>
|
||||
<FilePreview file={file} />
|
||||
</TrustProvider>
|
||||
</Flex>
|
||||
|
||||
<Flex mx="auto" maxW="6xl" w="full" gap="2" direction="column">
|
||||
<Flex gap="2">
|
||||
{type && <Text>{type}</Text>}
|
||||
{size && <Text>{formatBytes(parseInt(size))}</Text>}
|
||||
</Flex>
|
||||
|
||||
{sha256 && (
|
||||
<Box>
|
||||
<Text>SHA-256 hash:</Text>
|
||||
<Code fontFamily="monospace" py="1" px="2" userSelect="all">
|
||||
{sha256}
|
||||
</Code>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Divider mx="auto" maxW="6xl" w="full" />
|
||||
{summary && <Text whiteSpace="pre-line">{summary}</Text>}
|
||||
<Flex gap="2" wrap="wrap">
|
||||
<ButtonGroup gap="2" size="sm" variant="ghost">
|
||||
<EventZapButton event={file} showEventPreview={false} />
|
||||
<EventShareButton event={file} />
|
||||
<EventQuoteButton event={file} />
|
||||
</ButtonGroup>
|
||||
<NoteReactions event={file} size="sm" variant="ghost" />
|
||||
<Spacer />
|
||||
{magnet && (
|
||||
<Button as={Link} variant="link" leftIcon={<Magnet />} href={magnet} isExternal p="2">
|
||||
magnet
|
||||
</Button>
|
||||
)}
|
||||
<FileDownloadButton file={file} colorScheme="primary" />
|
||||
</Flex>
|
||||
</Flex>
|
||||
|
||||
<Flex mx="auto" maxW="4xl" w="full" gap="2" direction="column">
|
||||
{comment.isOpen ? (
|
||||
<GenericCommentForm event={file} onCancel={comment.onClose} onSubmitted={comment.onClose} />
|
||||
) : (
|
||||
<Button leftIcon={<ThreadIcon />} onClick={comment.onOpen} mr="auto">
|
||||
Comment
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<GenericComments event={file} />
|
||||
</Flex>
|
||||
</VerticalPageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
export default function FileDetailsView() {
|
||||
const pointer = useParamsEventPointer("nevent");
|
||||
const file = useSingleEvent(pointer?.id, pointer?.relays ?? []);
|
||||
|
||||
if (!file) return <Spinner />;
|
||||
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<FileDetailsPage file={file} />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
47
src/views/files/file/preview.tsx
Normal file
47
src/views/files/file/preview.tsx
Normal file
@ -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 <TrustImage h="full" src={url} />;
|
||||
if (type?.startsWith("video/")) return <TrustVideo h="full" src={url} />;
|
||||
|
||||
if (type === "model/stl")
|
||||
return <STLViewer aspectRatio={16 / 10} width={1920} height={1080} w="full" h="auto" url={url} />;
|
||||
}
|
||||
|
||||
const image = getTagValue(file, "image");
|
||||
if (image) return <TrustImage h="full" src={image} />;
|
||||
|
||||
return (
|
||||
<Alert
|
||||
status="info"
|
||||
variant="subtle"
|
||||
flexDirection="column"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
textAlign="center"
|
||||
height="200px"
|
||||
>
|
||||
<AlertIcon boxSize="40px" mr={0} />
|
||||
<AlertTitle mt={4} mb={1} fontSize="lg">
|
||||
No preview!
|
||||
</AlertTitle>
|
||||
<AlertDescription maxWidth="sm">There is no preview for {type} files</AlertDescription>
|
||||
<FileDownloadButton file={file} colorScheme="primary" mx="auto" mt="4" />
|
||||
</Alert>
|
||||
);
|
||||
}
|
@ -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<HTMLTableRowElement>(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 (
|
||||
// <BlurhashImage
|
||||
// blurhash={parsed.blurhash}
|
||||
// width={64 * aspect}
|
||||
// height={64}
|
||||
// onClick={showImage.onOpen}
|
||||
// cursor="pointer"
|
||||
// w="full"
|
||||
// />
|
||||
// );
|
||||
// }
|
||||
|
||||
const ImageComponent = shouldBlur ? BlurredImage : Image;
|
||||
return (
|
||||
<Flex
|
||||
direction="column"
|
||||
gap="2"
|
||||
aspectRatio={1}
|
||||
backgroundImage={parsed.url}
|
||||
backgroundPosition="center"
|
||||
backgroundSize="cover"
|
||||
backgroundRepeat="no-repeat"
|
||||
borderRadius="lg"
|
||||
overflow="hidden"
|
||||
ref={ref}
|
||||
>
|
||||
<Flex gap="2" alignItems="center" backgroundColor="blackAlpha.500" mt="auto" p="2">
|
||||
<UserAvatarLink pubkey={event.pubkey} size="sm" />
|
||||
<UserLink pubkey={event.pubkey} fontWeight="bold" isTruncated />
|
||||
<Timestamp timestamp={event.created_at} />
|
||||
<Spacer />
|
||||
<EventZapButton event={event} size="sm" colorScheme="yellow" variant="outline" />
|
||||
</Flex>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
function VideoFile({ event }: { event: NostrEvent }) {
|
||||
const url = getFileUrl(event);
|
||||
|
||||
const ref = useEventIntersectionRef(event);
|
||||
const nevent = useShareableEventAddress(file);
|
||||
|
||||
return (
|
||||
<Flex direction="column" gap="2" ref={ref}>
|
||||
<Flex gap="2" alignItems="center">
|
||||
<UserAvatarLink pubkey={event.pubkey} size="sm" />
|
||||
<UserLink pubkey={event.pubkey} fontWeight="bold" />
|
||||
</Flex>
|
||||
<video src={url} controls />
|
||||
</Flex>
|
||||
<Tr ref={ref}>
|
||||
<Td maxW="xs">
|
||||
<Link as={RouterLink} to={`/files/${nevent}`}>
|
||||
{name}
|
||||
</Link>
|
||||
</Td>
|
||||
<Td>
|
||||
<Flex gap="2" alignItems="center">
|
||||
<UserAvatarLink size="xs" pubkey={file.pubkey} />
|
||||
<UserLink pubkey={file.pubkey} fontWeight="bold" />
|
||||
<UserDnsIdentityIcon pubkey={file.pubkey} />
|
||||
</Flex>
|
||||
</Td>
|
||||
<Td>{type}</Td>
|
||||
<Td>{size && formatBytes(parseInt(size))}</Td>
|
||||
<Td isNumeric>
|
||||
<Timestamp timestamp={file.created_at} />
|
||||
</Td>
|
||||
</Tr>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
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 (
|
||||
<TrustProvider trust>
|
||||
<ImageFile event={event} />
|
||||
</TrustProvider>
|
||||
);
|
||||
}
|
||||
if (VIDEO_TYPES.includes(mimeType)) {
|
||||
return <VideoFile event={event} />;
|
||||
}
|
||||
return <Text>Unknown mine type {mimeType}</Text>;
|
||||
}
|
||||
|
||||
function FilesPage() {
|
||||
function FilesHomePage() {
|
||||
const { listId, filter } = usePeopleListContext();
|
||||
const relays = useReadRelays();
|
||||
|
||||
const [selectedTypes, setSelectedTypes] = useState<string[]>(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() {
|
||||
</Flex>
|
||||
|
||||
<IntersectionObserverProvider callback={callback}>
|
||||
<SimpleGrid minChildWidth="20rem" spacing="2">
|
||||
{events?.map((event) => (
|
||||
<ErrorBoundary key={event.id} event={event}>
|
||||
<FileType event={event} />
|
||||
</ErrorBoundary>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
<TableContainer>
|
||||
<Table size="sm">
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th>Name</Th>
|
||||
<Th>Type</Th>
|
||||
<Th>Size</Th>
|
||||
<Th isNumeric>Created</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{files.map((file) => (
|
||||
<ErrorBoundary key={file.id} event={file}>
|
||||
<FileRow file={file} />
|
||||
</ErrorBoundary>
|
||||
))}
|
||||
</Tbody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</IntersectionObserverProvider>
|
||||
<TimelineActionAndStatus timeline={loader} />
|
||||
</VerticalPageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
export default function FilesView() {
|
||||
export default function FilesHomeView() {
|
||||
return (
|
||||
<PeopleListProvider>
|
||||
<FilesPage />
|
||||
<FilesHomePage />
|
||||
</PeopleListProvider>
|
||||
);
|
||||
}
|
||||
|
@ -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 <Navigate to={`/n/${link}`} replace />;
|
||||
if (k === kinds.LongFormArticle) return <Navigate to={`/articles/${link}`} replace />;
|
||||
if (k === WIKI_PAGE_KIND) return <Navigate to={`/wiki/page/${link}`} replace />;
|
||||
if (k === MEDIA_POST_KIND) return <Navigate to={`/media/${link}`} replace />;
|
||||
if (k === kinds.FileMetadata) return <Navigate to={`/files/${link}`} replace />;
|
||||
|
||||
if (!event && decoded.type === "naddr") return <LoadUnknownAddress pointer={decoded.data} link={link} />;
|
||||
if (!event && decoded.type === "nevent") return <LoadUnknownEvent pointer={decoded.data} link={link} />;
|
||||
|
@ -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 }) {
|
||||
</Flex>
|
||||
|
||||
<ButtonGroup ml="auto">
|
||||
<ShareButton event={post} />
|
||||
<QuoteEventButton event={post} />
|
||||
<EventShareButton event={post} />
|
||||
<EventQuoteButton event={post} />
|
||||
<DebugEventButton event={post} />
|
||||
</ButtonGroup>
|
||||
</Flex>
|
||||
|
@ -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[] = [
|
||||
|
@ -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 }) {
|
||||
<ButtonGroup ml="auto">
|
||||
<StreamFavoriteButton stream={stream} />
|
||||
<StreamOpenButton stream={stream} />
|
||||
<QuoteEventButton event={stream} title="Share stream" />
|
||||
<EventQuoteButton event={stream} title="Share stream" />
|
||||
<DebugEventButton event={stream} />
|
||||
<Button onClick={() => setShowChat((v) => !v)}>{showChat ? "Hide" : "Show"} Chat</Button>
|
||||
</ButtonGroup>
|
||||
@ -184,7 +184,7 @@ function MobileStreamPage({ stream }: { stream: NostrEvent }) {
|
||||
<ButtonGroup size="sm" ml="auto">
|
||||
<StreamFavoriteButton stream={stream} />
|
||||
<StreamOpenButton stream={stream} />
|
||||
<QuoteEventButton event={stream} title="Share stream" />
|
||||
<EventQuoteButton event={stream} title="Share stream" />
|
||||
<DebugEventButton event={stream} />
|
||||
<Button onClick={showChat.onOpen}>Show Chat</Button>
|
||||
</ButtonGroup>
|
||||
|
@ -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
|
||||
<Flex gap="2" alignItems="center">
|
||||
<ButtonGroup variant="ghost" size="sm">
|
||||
<IconButton aria-label="Reply" title="Reply" onClick={replyForm.onToggle} icon={<ReplyIcon />} />
|
||||
<ShareButton event={post.event} />
|
||||
<QuoteEventButton event={post.event} />
|
||||
<EventShareButton event={post.event} />
|
||||
<EventQuoteButton event={post.event} />
|
||||
<EventZapButton event={post.event} />
|
||||
</ButtonGroup>
|
||||
{!showReactionsOnNewLine && reactionButtons}
|
||||
|
@ -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 }) {
|
||||
</Flex>
|
||||
<ButtonGroup variant="ghost" size="sm">
|
||||
<EventZapButton event={torrent} />
|
||||
<QuoteEventButton event={torrent} />
|
||||
<EventQuoteButton event={torrent} />
|
||||
<Button as={Link} leftIcon={<Magnet boxSize={5} />} href={getTorrentMagnetLink(torrent)} isExternal>
|
||||
Download torrent
|
||||
</Button>
|
||||
|
@ -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
|
||||
<Button leftIcon={<ReplyIcon />} isDisabled>
|
||||
Comment
|
||||
</Button>
|
||||
<QuoteEventButton event={track} />
|
||||
<EventQuoteButton event={track} />
|
||||
<EventZapButton event={track} />
|
||||
</ButtonGroup>
|
||||
<ButtonGroup size="sm" ml="auto">
|
||||
|
78
src/views/user/files.tsx
Normal file
78
src/views/user/files.tsx
Normal file
@ -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<HTMLTableRowElement>(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 (
|
||||
<Tr ref={ref}>
|
||||
<Td maxW="xs">
|
||||
<Link as={RouterLink} to={`/files/${nevent}`}>
|
||||
{name}
|
||||
</Link>
|
||||
</Td>
|
||||
<Td>{type}</Td>
|
||||
<Td>{size && formatBytes(parseInt(size))}</Td>
|
||||
<Td isNumeric>
|
||||
<Timestamp timestamp={file.created_at} />
|
||||
</Td>
|
||||
</Tr>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<IntersectionObserverProvider callback={callback}>
|
||||
<VerticalPageLayout>
|
||||
<TableContainer>
|
||||
<Table size="sm">
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th>Name</Th>
|
||||
<Th>Type</Th>
|
||||
<Th>Size</Th>
|
||||
<Th isNumeric>Created</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{files.map((file) => (
|
||||
<FileRow key={file.id} file={file} />
|
||||
))}
|
||||
</Tbody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
<TimelineActionAndStatus timeline={loader} />
|
||||
</VerticalPageLayout>
|
||||
</IntersectionObserverProvider>
|
||||
);
|
||||
}
|
@ -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" },
|
||||
|
@ -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],
|
||||
|
@ -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 }) {
|
||||
<UserFollowButton pubkey={video.pubkey} size="sm" />
|
||||
<ButtonGroup ml="auto" size="sm" variant="ghost">
|
||||
<SimpleBookmarkButton event={video} aria-label="Bookmark video" title="Bookmark video" />
|
||||
<QuoteEventButton event={video} />
|
||||
<EventQuoteButton event={video} />
|
||||
</ButtonGroup>
|
||||
<VideoMenu video={video} aria-label="More options" size="sm" />
|
||||
</Flex>
|
||||
|
@ -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 }) {
|
||||
<Flex alignItems="flex-end" gap="2" ml="auto">
|
||||
<EventVoteButtons event={page} inline chevrons={false} />
|
||||
<ButtonGroup size="sm">
|
||||
<QuoteEventButton event={page} />
|
||||
<EventQuoteButton event={page} />
|
||||
<EventZapButton event={page} showEventPreview={false} />
|
||||
<WikiPageMenu page={page} aria-label="Page Options" />
|
||||
</ButtonGroup>
|
||||
|
Loading…
x
Reference in New Issue
Block a user