mirror of
https://github.com/hzrd149/nostrudel.git
synced 2025-03-17 21:31:43 +01:00
Add support for Olas media posts
This commit is contained in:
parent
3fc9c64cd4
commit
2f1d50af5e
5
.changeset/beige-dragons-explain.md
Normal file
5
.changeset/beige-dragons-explain.md
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
"nostrudel": minor
|
||||
---
|
||||
|
||||
Add support for NIP-22 comments on media posts
|
5
.changeset/violet-mails-provide.md
Normal file
5
.changeset/violet-mails-provide.md
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
"nostrudel": minor
|
||||
---
|
||||
|
||||
Add support for Olas media posts
|
12
package.json
12
package.json
@ -28,9 +28,9 @@
|
||||
"@codemirror/autocomplete": "^6.18.3",
|
||||
"@codemirror/lang-json": "^6.0.1",
|
||||
"@codemirror/language": "^6.10.6",
|
||||
"@codemirror/view": "^6.35.2",
|
||||
"@emotion/react": "^11.13.5",
|
||||
"@emotion/styled": "^11.13.5",
|
||||
"@codemirror/view": "^6.35.3",
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.0",
|
||||
"@getalby/bitcoin-connect": "^3.6.3",
|
||||
"@getalby/bitcoin-connect-react": "^3.6.3",
|
||||
"@noble/ciphers": "^1.1.3",
|
||||
@ -45,6 +45,7 @@
|
||||
"applesauce-channel": "next",
|
||||
"applesauce-content": "next",
|
||||
"applesauce-core": "next",
|
||||
"applesauce-factory": "next",
|
||||
"applesauce-lists": "next",
|
||||
"applesauce-net": "next",
|
||||
"applesauce-react": "next",
|
||||
@ -83,6 +84,7 @@
|
||||
"nostr-idb": "^2.2.0",
|
||||
"nostr-tools": "^2.10.4",
|
||||
"nostr-wasm": "^0.1.0",
|
||||
"nuka-carousel": "^8.1.1",
|
||||
"prettier": "^3.4.2",
|
||||
"react": "^18.3.1",
|
||||
"react-chartjs-2": "^5.2.0",
|
||||
@ -99,7 +101,7 @@
|
||||
"react-router-dom": "^6.28.0",
|
||||
"react-simplemde-editor": "^5.2.0",
|
||||
"react-singleton-hook": "^4.0.1",
|
||||
"react-use": "^17.5.1",
|
||||
"react-use": "^17.6.0",
|
||||
"react-virtualized-auto-sizer": "^1.0.24",
|
||||
"react-window": "^1.8.10",
|
||||
"remark-gfm": "^4.0.0",
|
||||
@ -132,7 +134,7 @@
|
||||
"@types/lodash.throttle": "^4.1.9",
|
||||
"@types/ngeohash": "^0.6.8",
|
||||
"@types/react": "^18.3.14",
|
||||
"@types/react-dom": "^18.3.2",
|
||||
"@types/react-dom": "^18.3.3",
|
||||
"@types/react-window": "^1.8.8",
|
||||
"@types/three": "^0.160.0",
|
||||
"@types/webscopeio__react-textarea-autocomplete": "^4.7.5",
|
||||
|
428
pnpm-lock.yaml
generated
428
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
11
src/app.tsx
11
src/app.tsx
@ -16,6 +16,8 @@ const DVMFeedView = lazy(() => import("./views/discovery/dvm-feed/feed"));
|
||||
const BlindspotHomeView = lazy(() => import("./views/discovery/blindspot"));
|
||||
const BlindspotFeedView = lazy(() => import("./views/discovery/blindspot/feed"));
|
||||
const RelayDiscoveryView = lazy(() => import("./views/discovery/relays/index"));
|
||||
const MediaFeedView = lazy(() => import("./views/media/index"));
|
||||
const MediaPostView = lazy(() => import("./views/media/media-post"));
|
||||
import SettingsView from "./views/settings";
|
||||
import NostrLinkView from "./views/link";
|
||||
import ProfileView from "./views/profile";
|
||||
@ -106,6 +108,7 @@ import ArticlesHomeView from "./views/articles";
|
||||
import ArticleView from "./views/articles/article";
|
||||
import WalletView from "./views/wallet";
|
||||
import SupportView from "./views/support";
|
||||
import UserMediaPostsTab from "./views/user/media-posts";
|
||||
const TracksView = lazy(() => import("./views/tracks"));
|
||||
const UserTracksTab = lazy(() => import("./views/user/tracks"));
|
||||
const UserVideosTab = lazy(() => import("./views/user/videos"));
|
||||
@ -258,6 +261,7 @@ const router = createHashRouter([
|
||||
{ path: "about", element: <UserAboutTab /> },
|
||||
{ path: "notes", element: <UserNotesTab /> },
|
||||
{ path: "articles", element: <UserArticlesTab /> },
|
||||
{ path: "media", element: <UserMediaPostsTab /> },
|
||||
{ path: "streams", element: <UserStreamsTab /> },
|
||||
{ path: "tracks", element: <UserTracksTab /> },
|
||||
{ path: "videos", element: <UserVideosTab /> },
|
||||
@ -365,6 +369,13 @@ const router = createHashRouter([
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "media",
|
||||
children: [
|
||||
{ path: "", element: <MediaFeedView /> },
|
||||
{ path: ":pointer", element: <MediaPostView /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "wiki",
|
||||
children: [
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { lazy } from "react";
|
||||
import { lazy, VideoHTMLAttributes } from "react";
|
||||
import styled from "@emotion/styled";
|
||||
|
||||
import { isStreamURL, isVideoURL } from "../../../helpers/url";
|
||||
@ -15,7 +15,7 @@ const StyledVideo = styled.video`
|
||||
z-index: 1;
|
||||
`;
|
||||
|
||||
function TrustVideo({ src }: { src: string }) {
|
||||
export function TrustVideo({ src, ...props }: { src: string } & VideoHTMLAttributes<HTMLVideoElement>) {
|
||||
const { blurImages } = useAppSettings();
|
||||
const { onClick, handleEvent, style } = useElementTrustBlur();
|
||||
|
||||
@ -26,6 +26,7 @@ function TrustVideo({ src }: { src: string }) {
|
||||
style={blurImages ? style : undefined}
|
||||
onClick={blurImages ? onClick : undefined}
|
||||
onPlay={blurImages ? handleEvent : undefined}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
@ -8,6 +8,7 @@ import Timestamp from "../../timestamp";
|
||||
import ReactionIcon from "../../event-reactions/reaction-icon";
|
||||
import { NoteLink } from "../../note/note-link";
|
||||
import { nip25 } from "nostr-tools";
|
||||
import DebugEventButton from "../../debug-modal/debug-event-button";
|
||||
|
||||
export default function EmbeddedReaction({ event, ...props }: Omit<CardProps, "children"> & { event: NostrEvent }) {
|
||||
const pointer = nip25.getReactedEventPointer(event);
|
||||
@ -24,6 +25,7 @@ export default function EmbeddedReaction({ event, ...props }: Omit<CardProps, "c
|
||||
{pointer && <NoteLink noteId={pointer.id} />}
|
||||
<Spacer />
|
||||
<Timestamp timestamp={event.created_at} />
|
||||
<DebugEventButton event={event} variant="ghost" size="xs" />
|
||||
</Flex>
|
||||
</Card>
|
||||
</TrustProvider>
|
||||
|
@ -10,6 +10,7 @@ export default function ReactionGroupButton({
|
||||
if (count <= 1) {
|
||||
return <IconButton icon={<ReactionIcon emoji={emoji} url={url} />} aria-label="Reaction" {...props} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Button leftIcon={<ReactionIcon emoji={emoji} url={url} />} title={emoji} {...props}>
|
||||
{count > 1 && count}
|
||||
|
62
src/components/media-post/media-post-card.tsx
Normal file
62
src/components/media-post/media-post-card.tsx
Normal file
@ -0,0 +1,62 @@
|
||||
import { Box, Button, ButtonGroup, Card, CardBody, CardFooter, CardHeader } from "@chakra-ui/react";
|
||||
import { NostrEvent } from "nostr-tools";
|
||||
import { Link as RouterLink } from "react-router-dom";
|
||||
|
||||
import UserAvatarLink from "../user/user-avatar-link";
|
||||
import UserLink from "../user/user-link";
|
||||
import UserDnsIdentity from "../user/user-dns-identity";
|
||||
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 RepostButton from "../note/timeline-note/components/repost-button";
|
||||
import QuoteEventButton from "../note/quote-event-button";
|
||||
import MediaPostSlides from "./media-slides";
|
||||
import MediaPostContents from "./media-post-content";
|
||||
import { getSharableEventAddress } from "../../services/event-relay-hint";
|
||||
import { ThreadIcon } from "../icons";
|
||||
import EventZapIconButton from "../zap/event-zap-icon-button";
|
||||
import Timestamp from "../timestamp";
|
||||
|
||||
export default function MediaPost({ post }: { post: NostrEvent }) {
|
||||
const nevent = getSharableEventAddress(post);
|
||||
|
||||
return (
|
||||
<TrustProvider event={post}>
|
||||
<Card maxW="2xl" mx="auto">
|
||||
<CardHeader display="flex" alignItems="center" gap="2" p="2">
|
||||
<UserAvatarLink pubkey={post.pubkey} />
|
||||
<Box>
|
||||
<UserLink pubkey={post.pubkey} fontWeight="bold" /> <Timestamp timestamp={post.created_at} />
|
||||
<br />
|
||||
<UserDnsIdentity pubkey={post.pubkey} />
|
||||
</Box>
|
||||
|
||||
<Button as={RouterLink} to={`/media/${nevent}`} leftIcon={<ThreadIcon boxSize={5} />} ml="auto">
|
||||
Comments
|
||||
</Button>
|
||||
</CardHeader>
|
||||
|
||||
<CardBody p="0" position="relative" display="flex" flexDirection="column" gap="2" minH="md">
|
||||
<MediaPostSlides post={post} />
|
||||
|
||||
{post.content.length > 0 && <MediaPostContents post={post} px="2" />}
|
||||
</CardBody>
|
||||
|
||||
<CardFooter p="2" display="flex" gap="2">
|
||||
<ButtonGroup size="sm" variant="ghost">
|
||||
<EventZapIconButton event={post} aria-label="Zap post" />
|
||||
<AddReactionButton event={post} />
|
||||
<EventReactionButtons event={post} max={4} />
|
||||
</ButtonGroup>
|
||||
|
||||
<ButtonGroup size="sm" variant="ghost" ml="auto">
|
||||
<RepostButton event={post} />
|
||||
<QuoteEventButton event={post} />
|
||||
<DebugEventButton event={post} variant="ghost" ml="auto" size="sm" alignSelf="flex-start" />
|
||||
</ButtonGroup>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</TrustProvider>
|
||||
);
|
||||
}
|
28
src/components/media-post/media-post-content.tsx
Normal file
28
src/components/media-post/media-post-content.tsx
Normal file
@ -0,0 +1,28 @@
|
||||
import { Box, BoxProps } from "@chakra-ui/react";
|
||||
import { NostrEvent } from "nostr-tools";
|
||||
import { useRenderedContent } from "applesauce-react/hooks";
|
||||
import { emojis, nostrMentions, links, hashtags } from "applesauce-content/text";
|
||||
|
||||
import { components } from "../content";
|
||||
import { renderGenericUrl } from "../content/links";
|
||||
import { nipDefinitions } from "../content/transform/nip-notation";
|
||||
|
||||
const transformers = [links, nostrMentions, emojis, hashtags, nipDefinitions];
|
||||
|
||||
const linkRenderers = [renderGenericUrl];
|
||||
|
||||
const MediaPostContentSymbol = Symbol.for("media-post-content");
|
||||
|
||||
export default function MediaPostContents({ post, ...props }: { post: NostrEvent } & Omit<BoxProps, "children">) {
|
||||
const content = useRenderedContent(post, components, {
|
||||
linkRenderers,
|
||||
transformers,
|
||||
cacheKey: MediaPostContentSymbol,
|
||||
});
|
||||
|
||||
return (
|
||||
<Box whiteSpace="pre-wrap" dir="auto" {...props}>
|
||||
{content}
|
||||
</Box>
|
||||
);
|
||||
}
|
141
src/components/media-post/media-slides.tsx
Normal file
141
src/components/media-post/media-slides.tsx
Normal file
@ -0,0 +1,141 @@
|
||||
import { Box, Flex, FlexProps, IconButton, Spacer } from "@chakra-ui/react";
|
||||
import { NostrEvent } from "nostr-tools";
|
||||
import { getMediaAttachments, MediaAttachment } from "applesauce-core/helpers/media-attachment";
|
||||
import { Carousel, useCarousel } from "nuka-carousel";
|
||||
import styled from "@emotion/styled";
|
||||
|
||||
import { TrustImage, TrustVideo } from "../content/links";
|
||||
import { isImageURL, isVideoURL } from "applesauce-core/helpers";
|
||||
import { ChevronLeftIcon, ChevronRightIcon } from "../icons";
|
||||
import ZapBubbles from "../note/timeline-note/components/zap-bubbles";
|
||||
|
||||
function CustomArrows() {
|
||||
const { currentPage, totalPages, wrapMode, goBack, goForward } = useCarousel();
|
||||
|
||||
const allowWrap = wrapMode === "wrap";
|
||||
const enablePrevNavButton = allowWrap || currentPage > 0;
|
||||
const enableNextNavButton = allowWrap || currentPage < totalPages - 1;
|
||||
|
||||
return (
|
||||
<Flex justifyContent="space-between" position="absolute" top="50%" right="0" left="0">
|
||||
<IconButton
|
||||
icon={<ChevronLeftIcon boxSize={8} />}
|
||||
onClick={goBack}
|
||||
aria-label="previous image"
|
||||
variant="ghost"
|
||||
h="24"
|
||||
w="12"
|
||||
isDisabled={!enablePrevNavButton}
|
||||
>
|
||||
PREV
|
||||
</IconButton>
|
||||
<IconButton
|
||||
icon={<ChevronRightIcon boxSize={8} />}
|
||||
onClick={goForward}
|
||||
aria-label="next image"
|
||||
variant="ghost"
|
||||
h="24"
|
||||
w="12"
|
||||
isDisabled={!enableNextNavButton}
|
||||
>
|
||||
NEXT
|
||||
</IconButton>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
function cls(...classes: string[]) {
|
||||
return classes.filter(Boolean).join(" ");
|
||||
}
|
||||
function PageIndicators() {
|
||||
const { totalPages, currentPage, goToPage } = useCarousel();
|
||||
|
||||
const className = (index: number) =>
|
||||
cls("nuka-page-indicator", currentPage === index ? "nuka-page-indicator-active" : "");
|
||||
|
||||
return (
|
||||
<div className="nuka-page-container" data-testid="pageIndicatorContainer">
|
||||
{[...Array(totalPages)].map((_, index) => (
|
||||
<button key={index} onClick={() => goToPage(index)} className={className(index)}>
|
||||
<span className="nuka-hidden">{index + 1}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MediaAttachmentSlide({ media }: { media: MediaAttachment }) {
|
||||
if (media.type?.startsWith("video/") || isVideoURL(media.url)) {
|
||||
return <TrustVideo src={media.url} poster={media.image} aria-description={media.alt} />;
|
||||
} else if (media.type?.startsWith("image/") || isImageURL(media.url)) {
|
||||
return <TrustImage src={media.url} alt={media.alt} maxH="full" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box aspectRatio={1} minW="lg">
|
||||
Unknown media type {media.type ?? "Unknown"}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const CustomCarousel = styled(Carousel)`
|
||||
& {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.nuka-slide-container {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.nuka-overflow {
|
||||
overflow-x: scroll;
|
||||
overflow-y: hidden;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.nuka-wrapper {
|
||||
height: 100%;
|
||||
}
|
||||
`;
|
||||
|
||||
export default function MediaPostSlides({
|
||||
post,
|
||||
showZaps = true,
|
||||
...props
|
||||
}: { post: NostrEvent; showZaps?: boolean } & Omit<FlexProps, "children">) {
|
||||
const attachments = getMediaAttachments(post);
|
||||
|
||||
if (attachments.length === 1)
|
||||
return (
|
||||
<Flex gap="2" direction="column" {...props}>
|
||||
<Flex justifyContent="center" overflow="hidden" flexGrow={1} alignItems="flex-start">
|
||||
<MediaAttachmentSlide media={attachments[0]} />
|
||||
</Flex>
|
||||
{showZaps && <ZapBubbles event={post} px="2" />}
|
||||
</Flex>
|
||||
);
|
||||
|
||||
return (
|
||||
<Flex gap="2" direction="column" {...props}>
|
||||
<CustomCarousel
|
||||
scrollDistance="screen"
|
||||
showDots
|
||||
arrows={<CustomArrows />}
|
||||
showArrows
|
||||
dots={
|
||||
<Flex gap="2" justifyContent="space-between" alignItems="center" px="2">
|
||||
{showZaps && <ZapBubbles event={post} />}
|
||||
<Spacer />
|
||||
<PageIndicators />
|
||||
</Flex>
|
||||
}
|
||||
>
|
||||
{attachments.map((media) => (
|
||||
<MediaAttachmentSlide key={media.sha256 || media.url} media={media} />
|
||||
))}
|
||||
</CustomCarousel>
|
||||
</Flex>
|
||||
);
|
||||
}
|
@ -55,6 +55,7 @@ export default function NoteZapButton({ event, allowComment, showEventPreview, .
|
||||
icon={<LightningIcon verticalAlign="sub" />}
|
||||
aria-label="Zap Note"
|
||||
title="Zap Note"
|
||||
colorScheme={hasZapped ? "primary" : undefined}
|
||||
{...props}
|
||||
onClick={onOpen}
|
||||
isDisabled={!canZap}
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { ReactNode, memo } from "react";
|
||||
import { kinds } from "nostr-tools";
|
||||
import { Box, Text } from "@chakra-ui/react";
|
||||
import { Box } from "@chakra-ui/react";
|
||||
|
||||
import { ErrorBoundary } from "../../error-boundary";
|
||||
import ReplyNote from "./reply-note";
|
||||
@ -16,6 +16,8 @@ import { TimelineNote } from "../../note/timeline-note";
|
||||
import useEventIntersectionRef from "../../../hooks/use-event-intersection-ref";
|
||||
import ArticleCard from "../../../views/articles/components/article-card";
|
||||
import EmbeddedUnknown from "../../embed-event/event-types/embedded-unknown";
|
||||
import { MEDIA_POST_KIND } from "../../../helpers/nostr/media";
|
||||
import MediaPost from "../../media-post/media-post-card";
|
||||
|
||||
function TimelineItem({ event, visible, minHeight }: { event: NostrEvent; visible: boolean; minHeight?: number }) {
|
||||
const ref = useEventIntersectionRef(event);
|
||||
@ -44,6 +46,9 @@ function TimelineItem({ event, visible, minHeight }: { event: NostrEvent; visibl
|
||||
case kinds.LongFormArticle:
|
||||
content = <ArticleCard article={event} />;
|
||||
break;
|
||||
case MEDIA_POST_KIND:
|
||||
content = <MediaPost post={event} />;
|
||||
break;
|
||||
default:
|
||||
content = <EmbeddedUnknown event={event} />;
|
||||
break;
|
||||
|
43
src/components/zap/event-zap-icon-button.tsx
Normal file
43
src/components/zap/event-zap-icon-button.tsx
Normal file
@ -0,0 +1,43 @@
|
||||
import { IconButton, IconButtonProps, useDisclosure } from "@chakra-ui/react";
|
||||
import { getZapSender } from "applesauce-core/helpers";
|
||||
|
||||
import useCurrentAccount from "../../hooks/use-current-account";
|
||||
import useEventZaps from "../../hooks/use-event-zaps";
|
||||
import eventZapsService from "../../services/event-zaps";
|
||||
import { NostrEvent } from "../../types/nostr-event";
|
||||
import { LightningIcon } from "../icons";
|
||||
import ZapModal from "../event-zap-modal";
|
||||
import useUserLNURLMetadata from "../../hooks/use-user-lnurl-metadata";
|
||||
import { getEventUID } from "../../helpers/nostr/event";
|
||||
import { useReadRelays } from "../../hooks/use-client-relays";
|
||||
|
||||
export default function EventZapIconButton({
|
||||
event,
|
||||
...props
|
||||
}: { event: NostrEvent } & Omit<IconButtonProps, "icon" | "onClick">) {
|
||||
const account = useCurrentAccount();
|
||||
const { metadata } = useUserLNURLMetadata(event.pubkey);
|
||||
const zaps = useEventZaps(getEventUID(event)) ?? [];
|
||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||
|
||||
const readRelays = useReadRelays();
|
||||
const onZapped = () => {
|
||||
onClose();
|
||||
eventZapsService.requestZaps(getEventUID(event), readRelays, true);
|
||||
};
|
||||
|
||||
const canZap = !!metadata?.allowsNostr || event.tags.some((t) => t[0] === "zap");
|
||||
|
||||
return (
|
||||
<>
|
||||
<IconButton
|
||||
icon={<LightningIcon verticalAlign="sub" color="yellow.400" />}
|
||||
{...props}
|
||||
onClick={onOpen}
|
||||
isDisabled={!canZap}
|
||||
/>
|
||||
|
||||
{isOpen && <ZapModal isOpen={isOpen} pubkey={event.pubkey} event={event} onClose={onClose} onZapped={onZapped} />}
|
||||
</>
|
||||
);
|
||||
}
|
17
src/const.ts
17
src/const.ts
@ -1,4 +1,5 @@
|
||||
import { safeRelayUrls } from "applesauce-core/helpers";
|
||||
import { getCoordinateFromAddressPointer, safeRelayUrls } from "applesauce-core/helpers";
|
||||
import { EventFactoryClient } from "applesauce-factory";
|
||||
import { kinds } from "nostr-tools";
|
||||
|
||||
export const DEFAULT_SEARCH_RELAYS = safeRelayUrls([
|
||||
@ -47,10 +48,14 @@ export const NOSTR_CONNECT_PERMISSIONS = [
|
||||
];
|
||||
|
||||
export const NEVER_ATTACH_CLIENT_TAG = [kinds.EncryptedDirectMessage];
|
||||
export const NIP_89_CLIENT_TAG = [
|
||||
"client",
|
||||
"noStrudel",
|
||||
"31990:266815e0c9210dfa324c6cba3573b14bee49da4209a9456f9484e5106cd408a5:1686066542546",
|
||||
];
|
||||
|
||||
export const NIP_89_CLIENT_APP: EventFactoryClient = {
|
||||
name: "noStrudel",
|
||||
address: {
|
||||
kind: kinds.Handlerinformation,
|
||||
pubkey: "266815e0c9210dfa324c6cba3573b14bee49da4209a9456f9484e5106cd408a5",
|
||||
identifier: "1686066542546",
|
||||
},
|
||||
};
|
||||
|
||||
export const SUPPORT_PUBKEY = "713978c3094081b34fcf2f5491733b0c22728cd3b7a6946519d40f5f08598af8";
|
||||
|
1
src/helpers/nostr/media.ts
Normal file
1
src/helpers/nostr/media.ts
Normal file
@ -0,0 +1 @@
|
||||
export const MEDIA_POST_KIND = 20;
|
@ -155,6 +155,7 @@ export function ensureTagContentMentions(draft: EventTemplate) {
|
||||
return updated;
|
||||
}
|
||||
|
||||
/** @deprecated use includeContentHashtags from applesauce-factory instead */
|
||||
export function createHashtagTags(draft: EventTemplate) {
|
||||
const updatedDraft: EventTemplate = { ...draft, tags: Array.from(draft.tags) };
|
||||
|
||||
@ -214,6 +215,7 @@ export function addPubkeyRelayHints(draft: EventTemplate) {
|
||||
};
|
||||
}
|
||||
|
||||
/** @deprecated use event factory instead */
|
||||
export function finalizeNote(draft: EventTemplate) {
|
||||
let updated: EventTemplate = { ...draft, tags: Array.from(draft.tags) };
|
||||
updated.content = correctContentMentions(updated.content);
|
||||
|
11
src/providers/global/event-factory-provider.tsx
Normal file
11
src/providers/global/event-factory-provider.tsx
Normal file
@ -0,0 +1,11 @@
|
||||
import { useObservable } from "applesauce-react/hooks";
|
||||
import { FactoryProvider } from "applesauce-react/providers";
|
||||
|
||||
import eventFactoryService from "../../services/event-factory";
|
||||
import { PropsWithChildren } from "react";
|
||||
|
||||
export default function EventFactoryProvider({ children }: PropsWithChildren) {
|
||||
const factory = useObservable(eventFactoryService.subject);
|
||||
|
||||
return <FactoryProvider factory={factory ?? undefined}>{children}</FactoryProvider>;
|
||||
}
|
@ -1,6 +1,6 @@
|
||||
import React, { useMemo } from "react";
|
||||
import { ChakraProvider, localStorageManager } from "@chakra-ui/react";
|
||||
import { QueryStoreProvider } from "applesauce-react";
|
||||
import { QueryStoreProvider } from "applesauce-react/providers";
|
||||
|
||||
import { SigningProvider } from "./signing-provider";
|
||||
import buildTheme from "../../theme";
|
||||
@ -12,6 +12,7 @@ import DMTimelineProvider from "./dms-provider";
|
||||
import PublishProvider from "./publish-provider";
|
||||
import WebOfTrustProvider from "./web-of-trust-provider";
|
||||
import { queryStore } from "../../services/event-store";
|
||||
import EventFactoryProvider from "./event-factory-provider";
|
||||
|
||||
function ThemeProviders({ children }: { children: React.ReactNode }) {
|
||||
const { theme: themeName, primaryColor, maxPageWidth } = useAppSettings();
|
||||
@ -33,17 +34,19 @@ export const GlobalProviders = ({ children }: { children: React.ReactNode }) =>
|
||||
<QueryStoreProvider store={queryStore}>
|
||||
<ThemeProviders>
|
||||
<SigningProvider>
|
||||
<PublishProvider>
|
||||
<NotificationsProvider>
|
||||
<DMTimelineProvider>
|
||||
<DefaultEmojiProvider>
|
||||
<UserEmojiProvider>
|
||||
<WebOfTrustProvider>{children}</WebOfTrustProvider>
|
||||
</UserEmojiProvider>
|
||||
</DefaultEmojiProvider>
|
||||
</DMTimelineProvider>
|
||||
</NotificationsProvider>
|
||||
</PublishProvider>
|
||||
<EventFactoryProvider>
|
||||
<PublishProvider>
|
||||
<NotificationsProvider>
|
||||
<DMTimelineProvider>
|
||||
<DefaultEmojiProvider>
|
||||
<UserEmojiProvider>
|
||||
<WebOfTrustProvider>{children}</WebOfTrustProvider>
|
||||
</UserEmojiProvider>
|
||||
</DefaultEmojiProvider>
|
||||
</DMTimelineProvider>
|
||||
</NotificationsProvider>
|
||||
</PublishProvider>
|
||||
</EventFactoryProvider>
|
||||
</SigningProvider>
|
||||
</ThemeProviders>
|
||||
</QueryStoreProvider>
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { PropsWithChildren, createContext, useCallback, useContext, useMemo, useState } from "react";
|
||||
import { useToast } from "@chakra-ui/react";
|
||||
import { EventTemplate, NostrEvent, UnsignedEvent, getEventHash, kinds } from "nostr-tools";
|
||||
import { EventTemplate, NostrEvent, UnsignedEvent, kinds } from "nostr-tools";
|
||||
import { includeClientTag } from "applesauce-factory/operations";
|
||||
|
||||
import { useSigningContext } from "./signing-provider";
|
||||
import { DraftNostrEvent } from "../../types/nostr-event";
|
||||
@ -9,11 +10,10 @@ import clientRelaysService from "../../services/client-relays";
|
||||
import RelaySet from "../../classes/relay-set";
|
||||
import { cloneEvent, getAllRelayHints, isReplaceable } from "../../helpers/nostr/event";
|
||||
import replaceableEventsService from "../../services/replaceable-events";
|
||||
import eventReactionsService from "../../services/event-reactions";
|
||||
import { localRelay } from "../../services/local-relay";
|
||||
import deleteEventService from "../../services/delete-events";
|
||||
import localSettings from "../../services/local-settings";
|
||||
import { NEVER_ATTACH_CLIENT_TAG, NIP_89_CLIENT_TAG } from "../../const";
|
||||
import { NEVER_ATTACH_CLIENT_TAG, NIP_89_CLIENT_APP } from "../../const";
|
||||
import { eventStore } from "../../services/event-store";
|
||||
import { addPubkeyRelayHints } from "../../helpers/nostr/post";
|
||||
import useCurrentAccount from "../../hooks/use-current-account";
|
||||
@ -70,19 +70,24 @@ export default function PublishProvider({ children }: PropsWithChildren) {
|
||||
const outBoxes = useUserOutbox(account?.pubkey);
|
||||
|
||||
const finalizeDraft = useCallback<PublishContextType["finalizeDraft"]>(
|
||||
(event: EventTemplate | NostrEvent) => {
|
||||
async (event: EventTemplate | NostrEvent) => {
|
||||
let draft = cloneEvent(event.kind, event);
|
||||
|
||||
// add pubkey relay hints
|
||||
draft = addPubkeyRelayHints(draft);
|
||||
|
||||
// add client tag
|
||||
if (localSettings.addClientTag.value && !NEVER_ATTACH_CLIENT_TAG.includes(draft.kind)) {
|
||||
draft.tags = [...draft.tags.filter((t) => t[0] !== "client"), NIP_89_CLIENT_TAG];
|
||||
if (
|
||||
localSettings.addClientTag.value &&
|
||||
!NEVER_ATTACH_CLIENT_TAG.includes(draft.kind) &&
|
||||
!draft.tags.some((t) => t[0] === "client")
|
||||
) {
|
||||
// TODO: this should be removed when all events are created using the event factory
|
||||
draft = await includeClientTag(NIP_89_CLIENT_APP.name, NIP_89_CLIENT_APP.address)(draft, {});
|
||||
}
|
||||
|
||||
// request signature
|
||||
return signerFinalize(draft);
|
||||
return await signerFinalize(draft);
|
||||
},
|
||||
[signerFinalize],
|
||||
);
|
||||
|
32
src/services/event-factory.ts
Normal file
32
src/services/event-factory.ts
Normal file
@ -0,0 +1,32 @@
|
||||
import { BehaviorSubject, Observable } from "rxjs";
|
||||
import { EventFactory } from "applesauce-factory";
|
||||
import { Account } from "../classes/accounts/account";
|
||||
import { getEventRelayHints } from "./event-relay-hint";
|
||||
import { NIP_89_CLIENT_APP } from "../const";
|
||||
import accountService from "./account";
|
||||
|
||||
class EventFactoryService {
|
||||
subject = new BehaviorSubject<EventFactory | null>(null);
|
||||
|
||||
get factory() {
|
||||
return this.subject.value;
|
||||
}
|
||||
|
||||
constructor(account: Observable<Account | null>) {
|
||||
account.subscribe((current) => {
|
||||
if (!current) this.subject.next(null);
|
||||
else
|
||||
this.subject.next(
|
||||
new EventFactory({
|
||||
signer: current.signer,
|
||||
getRelayHint: (event) => getEventRelayHints(event, 1)[0],
|
||||
client: NIP_89_CLIENT_APP,
|
||||
}),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const eventFactoryService = new EventFactoryService(accountService.current);
|
||||
|
||||
export default eventFactoryService;
|
50
src/views/media/index.tsx
Normal file
50
src/views/media/index.tsx
Normal file
@ -0,0 +1,50 @@
|
||||
import { useCallback } from "react";
|
||||
import { Flex } from "@chakra-ui/react";
|
||||
import { NostrEvent } from "nostr-tools";
|
||||
|
||||
import PeopleListProvider, { usePeopleListContext } from "../../providers/local/people-list-provider";
|
||||
import useClientSideMuteFilter from "../../hooks/use-client-side-mute-filter";
|
||||
import { useReadRelays } from "../../hooks/use-client-relays";
|
||||
import useTimelineLoader from "../../hooks/use-timeline-loader";
|
||||
import { MEDIA_POST_KIND } from "../../helpers/nostr/media";
|
||||
import PeopleListSelection from "../../components/people-list-selection/people-list-selection";
|
||||
import TimelinePage from "../../components/timeline-page";
|
||||
|
||||
function MediaFeedPage() {
|
||||
const muteFilter = useClientSideMuteFilter();
|
||||
const eventFilter = useCallback(
|
||||
(event: NostrEvent) => {
|
||||
if (muteFilter(event)) return false;
|
||||
return true;
|
||||
},
|
||||
[muteFilter],
|
||||
);
|
||||
|
||||
const relays = useReadRelays();
|
||||
const { listId, filter } = usePeopleListContext();
|
||||
|
||||
const { loader, timeline } = useTimelineLoader(
|
||||
`${listId}-media-feed`,
|
||||
relays,
|
||||
filter ? { ...filter, kinds: [MEDIA_POST_KIND] } : undefined,
|
||||
{
|
||||
eventFilter,
|
||||
},
|
||||
);
|
||||
|
||||
const header = (
|
||||
<Flex gap="2" wrap="wrap" alignItems="center">
|
||||
<PeopleListSelection />
|
||||
</Flex>
|
||||
);
|
||||
|
||||
return <TimelinePage loader={loader} timeline={timeline} header={header} pt="2" pb="12" px="2" />;
|
||||
}
|
||||
|
||||
export default function MediaFeedView() {
|
||||
return (
|
||||
<PeopleListProvider>
|
||||
<MediaFeedPage />
|
||||
</PeopleListProvider>
|
||||
);
|
||||
}
|
44
src/views/media/media-comments.tsx
Normal file
44
src/views/media/media-comments.tsx
Normal file
@ -0,0 +1,44 @@
|
||||
import { Box, ButtonGroup } from "@chakra-ui/react";
|
||||
import { NostrEvent } from "nostr-tools";
|
||||
import { COMMENT_KIND } from "applesauce-core/helpers";
|
||||
import { useStoreQuery } from "applesauce-react/hooks";
|
||||
import { CommentsQuery } from "applesauce-core/queries";
|
||||
|
||||
import { useReadRelays } from "../../hooks/use-client-relays";
|
||||
import UserLink from "../../components/user/user-link";
|
||||
import DebugEventButton from "../../components/debug-modal/debug-event-button";
|
||||
import useTimelineLoader from "../../hooks/use-timeline-loader";
|
||||
import IntersectionObserverProvider from "../../providers/local/intersection-observer";
|
||||
import { useTimelineCurserIntersectionCallback } from "../../hooks/use-timeline-cursor-intersection-callback";
|
||||
import TextNoteContents from "../../components/note/timeline-note/text-note-contents";
|
||||
import Timestamp from "../../components/timestamp";
|
||||
|
||||
function Comment({ comment }: { comment: NostrEvent }) {
|
||||
return (
|
||||
<Box>
|
||||
<ButtonGroup float="right" variant="ghost" size="sm" alignItems="center">
|
||||
<Timestamp timestamp={comment.created_at} />
|
||||
<DebugEventButton event={comment} />
|
||||
</ButtonGroup>
|
||||
<Box float="left" mr="2">
|
||||
<UserLink pubkey={comment.pubkey} fontWeight="bold" />
|
||||
</Box>
|
||||
|
||||
<TextNoteContents event={comment} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export function MediaPostComments({ post }: { post: NostrEvent }) {
|
||||
const readRelays = useReadRelays();
|
||||
const { loader } = useTimelineLoader(`${post.id}-comments`, readRelays, { kinds: [COMMENT_KIND], "#E": [post.id] });
|
||||
|
||||
const comments = useStoreQuery(CommentsQuery, [post]);
|
||||
const callback = useTimelineCurserIntersectionCallback(loader);
|
||||
|
||||
return (
|
||||
<IntersectionObserverProvider callback={callback}>
|
||||
{comments?.map((comment) => <Comment key={comment.id} comment={comment} />)}
|
||||
</IntersectionObserverProvider>
|
||||
);
|
||||
}
|
67
src/views/media/media-post-comment-form.tsx
Normal file
67
src/views/media/media-post-comment-form.tsx
Normal file
@ -0,0 +1,67 @@
|
||||
import { useRef } from "react";
|
||||
import { Box, ComponentWithAs, Flex, FlexProps, IconButton, useToast } from "@chakra-ui/react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { NostrEvent } from "nostr-tools";
|
||||
import { useEventFactory } from "applesauce-react/hooks";
|
||||
|
||||
import { usePublishEvent } from "../../providers/global/publish-provider";
|
||||
import { useContextEmojis } from "../../providers/global/emoji-provider";
|
||||
import { MagicInput, RefType } from "../../components/magic-textarea";
|
||||
import useTextAreaUploadFile, { useTextAreaInsertTextWithForm } from "../../hooks/use-textarea-upload-file";
|
||||
import { useWriteRelays } from "../../hooks/use-client-relays";
|
||||
import MessageSquare01 from "../../components/icons/message-square-01";
|
||||
|
||||
export default function MediaPostCommentForm({
|
||||
post,
|
||||
...props
|
||||
}: { post: NostrEvent } & Omit<FlexProps, "children" | "as" | "onSubmit">) {
|
||||
const toast = useToast();
|
||||
const publish = usePublishEvent();
|
||||
const emojis = useContextEmojis();
|
||||
const factory = useEventFactory();
|
||||
|
||||
const relays = useWriteRelays();
|
||||
const { setValue, handleSubmit, formState, reset, getValues, watch } = useForm({
|
||||
defaultValues: { content: "" },
|
||||
});
|
||||
const sendMessage = handleSubmit(async (values) => {
|
||||
try {
|
||||
if (!factory) throw new Error("Missing factory");
|
||||
let draft = await factory.comment(post, values.content);
|
||||
const pub = await publish("Comment", draft, relays);
|
||||
if (pub) reset();
|
||||
} catch (error) {
|
||||
if (error instanceof Error) toast({ description: error.message, status: "error" });
|
||||
}
|
||||
});
|
||||
|
||||
const textAreaRef = useRef<RefType | null>(null);
|
||||
const insertText = useTextAreaInsertTextWithForm(textAreaRef, getValues, setValue);
|
||||
const { onPaste } = useTextAreaUploadFile(insertText);
|
||||
|
||||
watch("content");
|
||||
|
||||
return (
|
||||
<>
|
||||
<Flex as="form" onSubmit={sendMessage} gap="2" {...props}>
|
||||
<MagicInput
|
||||
instanceRef={(inst) => (textAreaRef.current = inst)}
|
||||
placeholder="Comment"
|
||||
autoComplete="off"
|
||||
isRequired
|
||||
value={getValues().content}
|
||||
onChange={(e) => setValue("content", e.target.value, { shouldDirty: true })}
|
||||
// @ts-expect-error
|
||||
onPaste={onPaste}
|
||||
/>
|
||||
<IconButton
|
||||
colorScheme="primary"
|
||||
type="submit"
|
||||
isLoading={formState.isSubmitting}
|
||||
icon={<MessageSquare01 />}
|
||||
aria-label="Comment"
|
||||
/>
|
||||
</Flex>
|
||||
</>
|
||||
);
|
||||
}
|
120
src/views/media/media-post.tsx
Normal file
120
src/views/media/media-post.tsx
Normal file
@ -0,0 +1,120 @@
|
||||
import { Box, ButtonGroup, Flex, Heading, Spinner } from "@chakra-ui/react";
|
||||
import { NostrEvent } from "nostr-tools";
|
||||
import { COMMENT_KIND } from "applesauce-core/helpers";
|
||||
import { useStoreQuery } from "applesauce-react/hooks";
|
||||
import { CommentsQuery } from "applesauce-core/queries";
|
||||
|
||||
import { useReadRelays } from "../../hooks/use-client-relays";
|
||||
import useParamsEventPointer from "../../hooks/use-params-event-pointer";
|
||||
import useSingleEvent from "../../hooks/use-single-event";
|
||||
import UserAvatarLink from "../../components/user/user-avatar-link";
|
||||
import UserLink from "../../components/user/user-link";
|
||||
import UserDnsIdentity from "../../components/user/user-dns-identity";
|
||||
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 RepostButton from "../../components/note/timeline-note/components/repost-button";
|
||||
import QuoteEventButton from "../../components/note/quote-event-button";
|
||||
import useTimelineLoader from "../../hooks/use-timeline-loader";
|
||||
import IntersectionObserverProvider from "../../providers/local/intersection-observer";
|
||||
import { useTimelineCurserIntersectionCallback } from "../../hooks/use-timeline-cursor-intersection-callback";
|
||||
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";
|
||||
import EventReactionButtons from "../../components/event-reactions/event-reactions";
|
||||
import { MediaPostComments } from "./media-comments";
|
||||
import MediaPostCommentForm from "./media-post-comment-form";
|
||||
|
||||
function Header({ post }: { post: NostrEvent }) {
|
||||
return (
|
||||
<Flex gap="2">
|
||||
<UserAvatarLink pubkey={post.pubkey} />
|
||||
<Flex direction="column">
|
||||
<UserLink pubkey={post.pubkey} fontWeight="bold" />
|
||||
<UserDnsIdentity pubkey={post.pubkey} />
|
||||
</Flex>
|
||||
|
||||
<ButtonGroup ml="auto">
|
||||
<RepostButton event={post} />
|
||||
<QuoteEventButton event={post} />
|
||||
<DebugEventButton event={post} />
|
||||
</ButtonGroup>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
function Actions({ post }: { post: NostrEvent }) {
|
||||
return (
|
||||
<ButtonGroup size="md" variant="ghost">
|
||||
<EventZapIconButton event={post} aria-label="Zap post" />
|
||||
<AddReactionButton event={post} />
|
||||
<EventReactionButtons event={post} />
|
||||
</ButtonGroup>
|
||||
);
|
||||
}
|
||||
|
||||
function HorizontalLayout({ post }: { post: NostrEvent }) {
|
||||
return (
|
||||
<Flex direction="column" pt="2" pb="4" gap="2" px="2" w="auto" h="100vh" overflow="hidden">
|
||||
<Header post={post} />
|
||||
|
||||
<Flex direction="row" gap="2" overflow="hidden" h="full">
|
||||
<Flex overflow="hidden" w="full" h="full" direction="column" gap="2">
|
||||
<MediaPostSlides post={post} maxH="full" overflow="hidden" />
|
||||
|
||||
<Actions post={post} />
|
||||
</Flex>
|
||||
|
||||
<Flex direction="column" w="md" overflowY="auto" flexShrink={0}>
|
||||
<MediaPostContents post={post} />
|
||||
|
||||
<Heading size="sm" mt="2">
|
||||
Comments:
|
||||
</Heading>
|
||||
<MediaPostCommentForm post={post} mb="2" />
|
||||
<MediaPostComments post={post} />
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
function VerticalLayout({ post }: { post: NostrEvent }) {
|
||||
return (
|
||||
<Flex direction="column" pt="2" pb="12" gap="2" px="2" w="full">
|
||||
<Header post={post} />
|
||||
|
||||
<MediaPostSlides post={post} h="full" overflow="hidden" />
|
||||
<MediaPostContents post={post} />
|
||||
|
||||
<Actions post={post} />
|
||||
|
||||
<Heading size="sm" my="2">
|
||||
Comments:
|
||||
</Heading>
|
||||
<MediaPostCommentForm post={post} mb="2" />
|
||||
<MediaPostComments post={post} />
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
function MediaPostPage({ post }: { post: NostrEvent }) {
|
||||
const Layout = useBreakpointValue({ base: VerticalLayout, lg: HorizontalLayout }) || VerticalLayout;
|
||||
|
||||
return (
|
||||
<TrustProvider trust>
|
||||
<Layout post={post} />
|
||||
</TrustProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default function MediaPostView() {
|
||||
const pointer = useParamsEventPointer("pointer");
|
||||
const readRelays = useReadRelays(pointer.relays);
|
||||
|
||||
const post = useSingleEvent(pointer.id, readRelays);
|
||||
|
||||
if (post) return <MediaPostPage post={post} />;
|
||||
else return <Spinner />;
|
||||
}
|
@ -23,6 +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 Camera01 from "../../components/icons/camera-01";
|
||||
|
||||
export const internalApps: App[] = [
|
||||
{
|
||||
@ -32,6 +33,13 @@ export const internalApps: App[] = [
|
||||
id: "streams",
|
||||
to: "/streams",
|
||||
},
|
||||
{
|
||||
title: "Media",
|
||||
description: "Browser media posts",
|
||||
icon: Camera01,
|
||||
id: "media",
|
||||
to: "/media",
|
||||
},
|
||||
{
|
||||
title: "Communities",
|
||||
description: "Create and manage communities",
|
||||
|
@ -22,6 +22,8 @@ import { useUserOutbox } from "../../../hooks/use-user-mailboxes";
|
||||
import { useReadRelays } from "../../../hooks/use-client-relays";
|
||||
import AlertTriangle from "../../../components/icons/alert-triangle";
|
||||
import MessageSquare02 from "../../../components/icons/message-square-02";
|
||||
import Camera01 from "../../../components/icons/camera-01";
|
||||
import { MEDIA_POST_KIND } from "../../../helpers/nostr/media";
|
||||
|
||||
type KnownKind = {
|
||||
kind: number;
|
||||
@ -75,6 +77,13 @@ const KnownKinds: KnownKind[] = [
|
||||
link: (_, p) => `/u/${npubEncode(p)}/articles`,
|
||||
},
|
||||
|
||||
{
|
||||
kind: MEDIA_POST_KIND,
|
||||
name: "Media",
|
||||
icon: Camera01,
|
||||
link: (_, p) => `/u/${npubEncode(p)}/media`,
|
||||
},
|
||||
|
||||
{
|
||||
kind: kinds.EncryptedDirectMessage,
|
||||
name: "Legacy DMs",
|
||||
|
@ -45,6 +45,7 @@ const tabs = [
|
||||
{ label: "Notes", path: "notes" },
|
||||
{ label: "Articles", path: "articles" },
|
||||
{ label: "Streams", path: "streams" },
|
||||
{ label: "Media", path: "media" },
|
||||
{ label: "Zaps", path: "zaps" },
|
||||
{ label: "Lists", path: "lists" },
|
||||
{ label: "Following", path: "following" },
|
||||
|
18
src/views/user/media-posts.tsx
Normal file
18
src/views/user/media-posts.tsx
Normal file
@ -0,0 +1,18 @@
|
||||
import { useOutletContext } from "react-router-dom";
|
||||
|
||||
import { useAdditionalRelayContext } from "../../providers/local/additional-relay-context";
|
||||
import useTimelineLoader from "../../hooks/use-timeline-loader";
|
||||
import TimelinePage from "../../components/timeline-page";
|
||||
import { MEDIA_POST_KIND } from "../../helpers/nostr/media";
|
||||
|
||||
export default function UserMediaPostsTab() {
|
||||
const { pubkey } = useOutletContext() as { pubkey: string };
|
||||
const readRelays = useAdditionalRelayContext();
|
||||
|
||||
const { loader, timeline } = useTimelineLoader(pubkey + "-media-posts", readRelays, {
|
||||
authors: [pubkey],
|
||||
kinds: [MEDIA_POST_KIND],
|
||||
});
|
||||
|
||||
return <TimelinePage loader={loader} timeline={timeline} pt="2" pb="12" px="2" />;
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user