Add simple file views and comments

This commit is contained in:
hzrd149 2025-01-08 16:40:46 -06:00
parent 747b7e2b8a
commit ee7a5b355f
34 changed files with 659 additions and 181 deletions

View File

@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Add simple file views and comments

View File

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

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

View File

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

View File

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

View File

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

View File

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

View 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>
);
}

View File

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

View File

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

View File

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

View File

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

View File

@ -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}
/>
)}

View File

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

View File

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

View File

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

View File

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

View 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>
);
}

View 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>
</>
);
}

View 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>
);
}

View 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>
);
}

View File

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

View File

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

View File

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

View File

@ -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[] = [

View File

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

View File

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

View File

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

View File

@ -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
View 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>
);
}

View File

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

View File

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

View File

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

View File

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